co-agent-coder 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.
Files changed (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
@@ -0,0 +1,375 @@
1
+ """BM25 retrieval over function-sized chunks (DESIGN §5.3, PRD FR-CTX-2).
2
+
3
+ What this is
4
+ ------------
5
+ Given a query (the task, a failing test name, a symbol the model asked about),
6
+ return the few source excerpts most likely to be relevant, formatted as citable
7
+ ``file:start-end`` blocks that fit the retrieved-excerpts slot of the §5.1
8
+ budget.
9
+
10
+ Why BM25 and not embeddings
11
+ ---------------------------
12
+ On 2 vCPU beside a GPU, an embedding index is a memory and latency tax for a
13
+ ranking gain BM25 mostly covers in a repo of this size. ``all-MiniLM-L6-v2`` is
14
+ an optional future upgrade, not a dependency and not part of v1.
15
+
16
+ Why chunks come from the repo map
17
+ ---------------------------------
18
+ The repo map already parses every file into ``Signature`` objects carrying line
19
+ spans. Re-parsing for retrieval would double the only real cost in the context
20
+ layer, so :class:`Retriever` consumes a :class:`RepoMap` and derives chunks
21
+ from it. Two consequences worth knowing:
22
+
23
+ * A chunk is a *function-sized* unit with its signature attached, so the model
24
+ always sees the name and parameters of what it is being shown.
25
+ * Files the map skipped (vendored, generated, non-Python) are not retrievable.
26
+ That is intentional: they are not editable either.
27
+
28
+ Ranking is BM25 plus a symbol-graph adjacency bonus: if a chunk's symbols are
29
+ defined in, or referenced from, a file already in context, that chunk gets
30
+ pushed up. This is the "callers/callees of the files already in context" part of
31
+ §5.3, and it is what lets the agent pull a helper into view without being told.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import math
37
+ import re
38
+ from collections import Counter
39
+ from dataclasses import dataclass, field
40
+
41
+ from .repo_map import RepoMap
42
+ from .tokens import estimate_tokens
43
+
44
+ __all__ = ["Chunk", "RetrievalConfig", "Retriever", "Recalled"]
45
+
46
+ #: §5.1 gives retrieved excerpts 3000 tokens, BM25, 5 chunks max.
47
+ DEFAULT_MAX_TOKENS = 3000
48
+ DEFAULT_MAX_CHUNKS = 5
49
+
50
+ #: A chunk longer than this is a class or a huge function. We cap the body so a
51
+ #: single 800-line method cannot consume the whole excerpt slot.
52
+ MAX_CHUNK_LINES = 80
53
+
54
+ #: BM25 parameters. k1 controls term-frequency saturation and b controls
55
+ #: length normalisation. 1.2 / 0.75 are the standard defaults and are fine for
56
+ #: source code, which has short documents and repetitive vocabulary.
57
+ BM25_K1 = 1.2
58
+ BM25_B = 0.75
59
+
60
+ #: Identifier-ish tokens. Code is not English: we keep snake_case and camelCase
61
+ #: whole, then split them, so `load_csv` matches a query for "csv loader".
62
+ _WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
63
+ _CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
64
+
65
+ #: Words that appear in almost every file and carry no signal. Kept small on
66
+ #: purpose: an aggressive stoplist on code drops real identifiers.
67
+ _STOPWORDS = frozenset(
68
+ {
69
+ "a", "an", "the", "and", "or", "if", "in", "is", "it", "of", "to", "for",
70
+ "self", "cls", "def", "class", "return", "import", "from", "as", "with",
71
+ "not", "none", "true", "false", "this", "that", "be", "on", "at", "by",
72
+ "pass", "else", "elif", "while", "try", "except", "raise", "lambda",
73
+ }
74
+ )
75
+
76
+
77
+ def _tokenize(text: str) -> list[str]:
78
+ """Split source into lowercase terms, exploding snake_case and camelCase."""
79
+ out: list[str] = []
80
+ for word in _WORD_RE.findall(text):
81
+ low = word.lower()
82
+ if low not in _STOPWORDS and len(low) > 1:
83
+ out.append(low)
84
+ # `load_csv` -> load, csv; `loadCsv` -> load, csv. The joined form is
85
+ # kept above, so both an exact and a split query match.
86
+ if "_" in word or any(c.isupper() for c in word[1:]):
87
+ parts = [p for p in re.split(r"_+", word) if p]
88
+ expanded: list[str] = []
89
+ for part in parts:
90
+ expanded.extend(_CAMEL_RE.findall(part) or [part])
91
+ for part in expanded:
92
+ p = part.lower()
93
+ if p not in _STOPWORDS and len(p) > 1:
94
+ out.append(p)
95
+ return out
96
+
97
+
98
+ @dataclass
99
+ class Chunk:
100
+ """One retrievable unit: a function, method, class head, or module prologue."""
101
+
102
+ rel: str
103
+ start_line: int
104
+ end_line: int
105
+ text: str
106
+ #: Signature-derived name, used for adjacency and for the excerpt header.
107
+ symbol: str | None = None
108
+ kind: str = "def"
109
+ #: Terms for BM25, computed once per chunk rather than per query.
110
+ terms: list[str] = field(default_factory=list)
111
+
112
+ @property
113
+ def locator(self) -> str:
114
+ return f"{self.rel}:{self.start_line}-{self.end_line}"
115
+
116
+ def header(self) -> str:
117
+ name = f" {self.symbol}" if self.symbol else ""
118
+ return f"--- {self.locator}{name} ---"
119
+
120
+ def render(self) -> str:
121
+ return f"{self.header()}\n{self.text}"
122
+
123
+
124
+ @dataclass
125
+ class RetrievalConfig:
126
+ max_tokens: int = DEFAULT_MAX_TOKENS
127
+ max_chunks: int = DEFAULT_MAX_CHUNKS
128
+ max_chunk_lines: int = MAX_CHUNK_LINES
129
+ #: Weight of the adjacency bonus relative to a normalised BM25 score.
130
+ adjacency_weight: float = 0.35
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Recalled:
135
+ """A scored chunk, returned so callers can log or re-rank."""
136
+
137
+ chunk: Chunk
138
+ score: float
139
+ bm25: float
140
+ adjacency: float
141
+
142
+ @property
143
+ def locator(self) -> str:
144
+ return self.chunk.locator
145
+
146
+
147
+ class Retriever:
148
+ """BM25 + symbol adjacency over chunks derived from a :class:`RepoMap`.
149
+
150
+ Usage from the loop::
151
+
152
+ retriever = Retriever(repo_map)
153
+ excerpts = retriever.render("chunked csv loader dtype", in_context={"src/frame.py"})
154
+
155
+ The index is built once per ``RepoMap`` and reused across queries; a query
156
+ costs one pass over the chunk list.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ repo_map: RepoMap,
162
+ *,
163
+ config: RetrievalConfig | None = None,
164
+ ) -> None:
165
+ self.repo_map = repo_map
166
+ self.config = config or RetrievalConfig()
167
+ self._chunks: list[Chunk] | None = None
168
+ self._df: Counter[str] = Counter()
169
+ self._avgdl: float = 0.0
170
+
171
+ # ------------------------------------------------------------------ index
172
+
173
+ @property
174
+ def avgdl(self) -> float:
175
+ """Mean chunk length, after ensuring the index exists."""
176
+ self.chunks()
177
+ return self._avgdl
178
+
179
+ @property
180
+ def doc_freq(self) -> Counter[str]:
181
+ """Document frequencies, after ensuring the index exists."""
182
+ self.chunks()
183
+ return self._df
184
+
185
+ def chunks(self) -> list[Chunk]:
186
+ """Build (once) and return the chunk corpus."""
187
+ if self._chunks is None:
188
+ self._chunks = self._build_chunks()
189
+ self._df = Counter()
190
+ for chunk in self._chunks:
191
+ for term in set(chunk.terms):
192
+ self._df[term] += 1
193
+ total = sum(len(c.terms) for c in self._chunks)
194
+ self._avgdl = total / len(self._chunks) if self._chunks else 0.0
195
+ return self._chunks
196
+
197
+ def _build_chunks(self) -> list[Chunk]:
198
+ """Cut every file into function-sized chunks using repo map spans."""
199
+ chunks: list[Chunk] = []
200
+ for fm in self.repo_map.files():
201
+ try:
202
+ source = (self.repo_map.root / fm.rel).read_text(encoding="utf-8", errors="replace")
203
+ except OSError:
204
+ continue
205
+ lines = source.splitlines()
206
+ if not lines:
207
+ continue
208
+ spans = [(s.start_line, s.end_line, s) for s in fm.signatures if s.start_line > 0]
209
+ spans.sort()
210
+
211
+ # The module prologue — imports and module docstring — is often the
212
+ # most informative part of a file about what it depends on.
213
+ first = spans[0][0] if spans else len(lines) + 1
214
+ if first > 1 and first <= 40:
215
+ chunks.append(
216
+ _make_chunk(fm.rel, 1, first - 1, lines, "<module>", "module", self.config)
217
+ )
218
+
219
+ for start, end, sig in spans:
220
+ if sig.kind == "const":
221
+ continue # a bare `X = ...` line is not worth an excerpt slot
222
+ chunks.append(
223
+ _make_chunk(fm.rel, start, end or start, lines, sig.name, sig.kind, self.config)
224
+ )
225
+ return chunks
226
+
227
+ # ------------------------------------------------------------------ query
228
+
229
+ def search(
230
+ self,
231
+ query: str,
232
+ *,
233
+ in_context: set[str] | None = None,
234
+ limit: int | None = None,
235
+ ) -> list[Recalled]:
236
+ """Rank chunks against ``query``, boosted by adjacency to ``in_context``.
237
+
238
+ ``in_context`` holds repo-relative paths already shown to the model
239
+ (files it read, or the file the task names). Chunks defined in those
240
+ files are excluded — re-showing them wastes the slot — while chunks that
241
+ *reference* their symbols get the adjacency bonus.
242
+ """
243
+ chunks = self.chunks()
244
+ if not chunks:
245
+ return []
246
+
247
+ terms = _tokenize(query)
248
+ if not terms:
249
+ return []
250
+
251
+ context = {_norm(p) for p in (in_context or set())}
252
+ context_symbols: set[str] = set()
253
+ for chunk in chunks:
254
+ if chunk.rel in context and chunk.symbol and chunk.symbol != "<module>":
255
+ context_symbols.add(chunk.symbol.lower())
256
+
257
+ n = len(chunks)
258
+ scores: list[Recalled] = []
259
+ for chunk in chunks:
260
+ if chunk.rel in context:
261
+ continue # already on screen
262
+ bm25 = self._bm25(terms, chunk, n)
263
+ adjacency = self._adjacency(chunk, context, context_symbols)
264
+ total = bm25 + self.config.adjacency_weight * adjacency
265
+ if total > 0:
266
+ scores.append(Recalled(chunk=chunk, score=total, bm25=bm25, adjacency=adjacency))
267
+
268
+ # Deterministic order: score, then locator. Without the tiebreak a
269
+ # re-query can shuffle equal-scoring chunks and invalidate a resumed
270
+ # session's cached context.
271
+ scores.sort(key=lambda r: (-r.score, r.chunk.rel, r.chunk.start_line))
272
+ cap = limit if limit is not None else self.config.max_chunks
273
+ return scores[:cap]
274
+
275
+ def _bm25(self, terms: list[str], chunk: Chunk, n: int) -> float:
276
+ """Okapi BM25, computed per-query (the corpus is small enough)."""
277
+ if not self._avgdl:
278
+ return 0.0
279
+ tf = Counter(chunk.terms)
280
+ dl = len(chunk.terms)
281
+ score = 0.0
282
+ for term in set(terms):
283
+ freq = tf.get(term, 0)
284
+ if not freq:
285
+ continue
286
+ df = self._df.get(term, 0)
287
+ # Standard BM25 IDF with the +1 inside the log so a term present in
288
+ # every document scores ~0 rather than going negative.
289
+ idf = math.log(1 + (n - df + 0.5) / (df + 0.5))
290
+ denom = freq + BM25_K1 * (1 - BM25_B + BM25_B * dl / self._avgdl)
291
+ score += idf * (freq * (BM25_K1 + 1)) / denom
292
+ return score
293
+
294
+ def _adjacency(self, chunk: Chunk, context: set[str], context_symbols: set[str]) -> float:
295
+ """Symbol-graph bonus: does this chunk touch what is already in context?
296
+
297
+ Two edges count — the chunk defines a symbol used in a context file, or
298
+ the chunk references a symbol defined in one. Both are 1.0; the caller
299
+ scales the result with ``adjacency_weight``.
300
+ """
301
+ if not context and not context_symbols:
302
+ return 0.0
303
+ chunk_terms = set(chunk.terms)
304
+ if not chunk_terms:
305
+ return 0.0
306
+ # Import of a module already in context, e.g. "from .frame import ...".
307
+ for rel in context:
308
+ stem = rel.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()
309
+ if len(stem) > 2 and stem in chunk_terms:
310
+ return 1.0
311
+ if chunk.symbol and chunk.symbol.lower() in context_symbols:
312
+ return 1.0
313
+ if context_symbols & chunk_terms:
314
+ return 1.0
315
+ return 0.0
316
+
317
+ # ----------------------------------------------------------------- render
318
+
319
+ def render(
320
+ self,
321
+ query: str,
322
+ *,
323
+ in_context: set[str] | None = None,
324
+ max_tokens: int | None = None,
325
+ ) -> str:
326
+ """Return the excerpts for ``query``, packed to the §5.1 slot.
327
+
328
+ Chunks are added whole until the budget is hit. A chunk that does not fit
329
+ is skipped rather than truncated: half a function is worse than a
330
+ different whole one, and the excerpt header would lie about the range.
331
+ """
332
+ budget = self.config.max_tokens if max_tokens is None else max_tokens
333
+ recalled = self.search(query, in_context=in_context)
334
+ if not recalled:
335
+ return ""
336
+
337
+ out: list[str] = []
338
+ used = 0
339
+ for hit in recalled:
340
+ block = hit.chunk.render()
341
+ cost = estimate_tokens(block)
342
+ if used + cost > budget:
343
+ continue
344
+ out.append(block)
345
+ used += cost
346
+ if len(out) >= self.config.max_chunks:
347
+ break
348
+ return "\n\n".join(out)
349
+
350
+
351
+ def _make_chunk(
352
+ rel: str,
353
+ start: int,
354
+ end: int,
355
+ lines: list[str],
356
+ symbol: str | None,
357
+ kind: str,
358
+ config: RetrievalConfig,
359
+ ) -> Chunk:
360
+ """Slice a chunk, capping the body so one huge function cannot hog the slot."""
361
+ end = min(end, start + config.max_chunk_lines - 1, len(lines))
362
+ body = "\n".join(lines[start - 1 : end])
363
+ return Chunk(
364
+ rel=rel,
365
+ start_line=start,
366
+ end_line=end,
367
+ text=body,
368
+ symbol=symbol,
369
+ kind=kind,
370
+ terms=_tokenize(body),
371
+ )
372
+
373
+
374
+ def _norm(path: str) -> str:
375
+ return path.replace("\\", "/").lstrip("./")
@@ -0,0 +1,93 @@
1
+ """Token accounting for the context ledger (DESIGN §5.1).
2
+
3
+ Estimating tokens must be **cheap and deterministic**, not exact. The loop
4
+ makes dozens of these decisions per task and there is no tokenizer available on
5
+ the CPU path — the model may not even be loaded yet. A ~4 chars/token heuristic
6
+ is within 10–15% for source code and, crucially, it is *monotone*: if the
7
+ estimate says a block fits, the real block is very unlikely to blow the window.
8
+
9
+ The budget here is the repo map's slot only (2000 tokens per §5.1). Everything
10
+ that packs text into a fixed number of tokens goes through :func:`fit`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__ = ["estimate_tokens", "fit", "truncate_middle"]
16
+
17
+ #: Mean characters per token for Python/JS source. Code is denser in punctuation
18
+ #: than prose, so it tokenizes closer to 3.5–4 chars than the 4-of-English rule.
19
+ CHARS_PER_TOKEN = 4
20
+
21
+ #: Marker appended when a block was cut; it must not be silently mistaken for
22
+ #: real map content, so it is counted in every fitting decision.
23
+ ELLIPSIS = "…"
24
+
25
+
26
+ def estimate_tokens(text: str) -> int:
27
+ """Estimate the token cost of ``text``.
28
+
29
+ Counts characters, not bytes: a non-ASCII identifier costs the same as an
30
+ ASCII one under a BPE tokenizer for the blends we target, and using bytes
31
+ would double-count UTF-8 continuation bytes.
32
+
33
+ Always returns at least 1 for non-empty text so that a one-character line
34
+ does not look free.
35
+ """
36
+ if not text:
37
+ return 0
38
+ return max(1, (len(text) + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN)
39
+
40
+
41
+ def truncate_middle(text: str, max_tokens: int) -> str:
42
+ """Keep the head and tail of ``text``, dropping the middle.
43
+
44
+ Head/tail is deliberate: for a repo map the head is the highest-ranked file
45
+ and the tail is the most recently added context, while the middle is the
46
+ least interesting. That matches the observation-truncation rule in
47
+ DESIGN §5.1 and keeps the two ends, which is where a model looks first.
48
+ """
49
+ if max_tokens <= 0:
50
+ return ""
51
+ if estimate_tokens(text) <= max_tokens:
52
+ return text
53
+
54
+ max_chars = max_tokens * CHARS_PER_TOKEN
55
+ marker = f"\n{ELLIPSIS} [truncated] {ELLIPSIS}\n"
56
+ if max_chars <= len(marker):
57
+ return text[:max_chars]
58
+
59
+ room = max_chars - len(marker)
60
+ head = int(room * 0.7)
61
+ tail = room - head
62
+ return text[:head] + marker + text[len(text) - tail :]
63
+
64
+
65
+ def fit(blocks: list[str], max_tokens: int, *, separator: str = "\n") -> str:
66
+ """Pack ``blocks`` in order until ``max_tokens`` is reached.
67
+
68
+ Order is the caller's ranking order, so this is a greedy pack (DESIGN §5.2:
69
+ "pack greedily into the 2k budget"). A block that does not fit is not
70
+ allowed to preempt a smaller one later in the list — the ranking decided the
71
+ order and we stop at the first overflow, which keeps the output stable
72
+ across runs instead of reshuffling on every estimate.
73
+ """
74
+ if max_tokens <= 0 or not blocks:
75
+ return ""
76
+
77
+ out: list[str] = []
78
+ used = 0
79
+ for block in blocks:
80
+ if not block:
81
+ continue
82
+ cost = estimate_tokens(block) + (1 if out else 0)
83
+ if used + cost > max_tokens:
84
+ break
85
+ out.append(block)
86
+ used += cost
87
+
88
+ if not out:
89
+ # Nothing fit whole: fall back to the first block, truncated. A partial
90
+ # top-ranked file beats an empty map.
91
+ return truncate_middle(blocks[0], max_tokens)
92
+
93
+ return separator.join(out)
File without changes
@@ -0,0 +1,52 @@
1
+ """gitx — git as the agent's database (DESIGN §3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .commit import TRAILER_ORDER, CommitMeta, build_message, commit_step, parse_trailers
6
+ from .durable import SyncResult, checkpoint, sync_bundle, sync_push
7
+ from .git import GitError, GitRepo, GitResult
8
+ from .lock import LockHeldError, LockInfo, SessionLock
9
+ from .log import LogRecord, SessionLog
10
+ from .resume import Briefing, PlanItem, build_briefing, read_plan_items
11
+ from .session import (
12
+ SESSION_BRANCH_PREFIX,
13
+ DirtyTreeError,
14
+ Session,
15
+ SessionError,
16
+ clone_repo,
17
+ new_session_id,
18
+ open_session,
19
+ short_id,
20
+ )
21
+
22
+ __all__ = [
23
+ "SESSION_BRANCH_PREFIX",
24
+ "TRAILER_ORDER",
25
+ "Briefing",
26
+ "CommitMeta",
27
+ "DirtyTreeError",
28
+ "GitError",
29
+ "GitRepo",
30
+ "GitResult",
31
+ "LockHeldError",
32
+ "LockInfo",
33
+ "LogRecord",
34
+ "PlanItem",
35
+ "Session",
36
+ "SessionError",
37
+ "SessionLock",
38
+ "SessionLog",
39
+ "SyncResult",
40
+ "build_briefing",
41
+ "build_message",
42
+ "checkpoint",
43
+ "clone_repo",
44
+ "commit_step",
45
+ "new_session_id",
46
+ "open_session",
47
+ "parse_trailers",
48
+ "read_plan_items",
49
+ "short_id",
50
+ "sync_bundle",
51
+ "sync_push",
52
+ ]
coagent/gitx/commit.py ADDED
@@ -0,0 +1,91 @@
1
+ """The commit protocol (PRD FR-GIT-3, Appendix B).
2
+
3
+ One commit per verified step, with six machine-readable trailers. That gives
4
+ `git log --grep="Coagent-Task: add CSV"`, `git diff <step-6> <step-7>` as replay,
5
+ a blame trail naming the model and step, and `git revert` as a per-action undo.
6
+
7
+ Trailers are written verbatim (no wrapping, no rewriting), and values are
8
+ sanitized to a single line so a newline inside a task description cannot break
9
+ the header block that `--grep` depends on.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from .git import GitRepo, build_commit_message
17
+
18
+ __all__ = ["CommitMeta", "TRAILER_ORDER", "build_message", "commit_step", "parse_trailers"]
19
+
20
+ TRAILER_ORDER: tuple[str, ...] = (
21
+ "Coagent-Session",
22
+ "Coagent-Step",
23
+ "Coagent-Task",
24
+ "Coagent-Verify",
25
+ "Coagent-Model",
26
+ "Coagent-Artifacts",
27
+ )
28
+
29
+ _MAX_TRAILER_LEN = 200
30
+
31
+
32
+ def _one_line(value: str, *, limit: int = _MAX_TRAILER_LEN) -> str:
33
+ """Collapse a value onto one line and cap it — trailers are headers."""
34
+ flat = " ".join(str(value).split())
35
+ if len(flat) > limit:
36
+ flat = flat[: limit - 1] + "…"
37
+ return flat
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class CommitMeta:
42
+ """Everything a step commit records."""
43
+
44
+ session: str
45
+ step: int
46
+ task: str
47
+ verify: str = "not run"
48
+ model: str = "unknown"
49
+ artifacts: list[str] = field(default_factory=list)
50
+ subject: str = ""
51
+ body: str = ""
52
+
53
+ def trailers(self) -> list[tuple[str, str]]:
54
+ values = {
55
+ "Coagent-Session": self.session,
56
+ "Coagent-Step": str(self.step),
57
+ "Coagent-Task": _one_line(self.task or "unspecified"),
58
+ "Coagent-Verify": _one_line(self.verify or "not run"),
59
+ "Coagent-Model": _one_line(self.model or "unknown"),
60
+ "Coagent-Artifacts": _one_line(
61
+ ", ".join(self.artifacts) if self.artifacts else "none"
62
+ ),
63
+ }
64
+ return [(key, values[key]) for key in TRAILER_ORDER]
65
+
66
+ def default_subject(self) -> str:
67
+ return self.subject.strip() or _one_line(self.task or "coagent step", limit=72)
68
+
69
+
70
+ def build_message(meta: CommitMeta) -> str:
71
+ return build_commit_message(meta.default_subject(), meta.body, meta.trailers())
72
+
73
+
74
+ def commit_step(repo: GitRepo, meta: CommitMeta) -> str:
75
+ """Stage tracked changes and commit them with the full trailer set."""
76
+ repo.add_all_tracked()
77
+ if not repo.has_staged_changes():
78
+ # Nothing changed: committing an empty step would corrupt the step
79
+ # numbering that `git log --grep` relies on.
80
+ raise ValueError("nothing to commit: no tracked changes are staged")
81
+ return repo.commit_with_trailers(meta.default_subject(), meta.body, meta.trailers())
82
+
83
+
84
+ def parse_trailers(message: str) -> dict[str, str]:
85
+ """Read the coagent trailers back out of a commit message."""
86
+ found: dict[str, str] = {}
87
+ for line in message.splitlines():
88
+ key, sep, value = line.partition(": ")
89
+ if sep and key.startswith("Coagent-"):
90
+ found[key] = value.strip()
91
+ return found