@mindfoldhq/trellis 0.6.10 → 0.7.0-beta.1
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.
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +20 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +0 -8
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts +17 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +222 -0
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/opencode.d.ts.map +1 -1
- package/dist/configurators/opencode.js +4 -0
- package/dist/configurators/opencode.js.map +1 -1
- package/dist/configurators/workflow.d.ts +0 -14
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +1 -37
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/migrations/manifests/0.7.0-beta.0.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.1.json +9 -0
- package/dist/templates/claude/settings.json +22 -0
- package/dist/templates/codex/agents/trellis-check.toml +2 -2
- package/dist/templates/codex/agents/trellis-implement.toml +2 -2
- package/dist/templates/codex/agents/trellis-research.toml +2 -2
- package/dist/templates/codex/hooks/session-start.py +20 -1
- package/dist/templates/codex/hooks.json +25 -0
- package/dist/templates/common/bundled-skills/trellis-meta/references/platform-files/hooks-and-settings.md +2 -1
- package/dist/templates/copilot/hooks/session-start.py +20 -1
- package/dist/templates/opencode/plugins/inject-spec-context.js +121 -0
- package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
- package/dist/templates/pi/extensions/trellis/index.ts.txt +61 -4
- package/dist/templates/shared-hooks/index.d.ts +8 -2
- package/dist/templates/shared-hooks/index.d.ts.map +1 -1
- package/dist/templates/shared-hooks/index.js +13 -1
- package/dist/templates/shared-hooks/index.js.map +1 -1
- package/dist/templates/shared-hooks/inject-spec-context.py +844 -0
- package/dist/templates/shared-hooks/inject-subagent-context.py +4 -3
- package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
- package/dist/templates/shared-hooks/session-start.py +22 -1
- package/dist/templates/trellis/config.yaml +47 -0
- package/dist/templates/trellis/index.d.ts +4 -3
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +7 -3
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +0 -45
- package/dist/templates/trellis/scripts/common/config.py +18 -0
- package/dist/templates/trellis/scripts/common/git_context.py +34 -2
- package/dist/templates/trellis/scripts/common/paths.py +28 -0
- package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
- package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
- package/dist/templates/trellis/scripts/common/task_store.py +30 -0
- package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
- package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
- package/dist/templates/trellis/scripts/task.py +82 -0
- package/package.json +2 -2
- package/dist/templates/trellis/gitattributes.txt +0 -9
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Decision logic for path-scoped spec injection (ticket-refresh model).
|
|
5
|
+
|
|
6
|
+
Pure logic only: the per-spec decision engine, block rendering and budgeted
|
|
7
|
+
payload assembly. Importing this module has no side effects; every piece of IO
|
|
8
|
+
orchestration (stdin, config, identity, state files, locking, GC) lives in the
|
|
9
|
+
platform hook that calls it. Unit tests import this module directly.
|
|
10
|
+
|
|
11
|
+
Clock
|
|
12
|
+
The periodic refresh window uses epoch seconds. Context resets are
|
|
13
|
+
explicit lifecycle events: the hook records an opaque reset identifier,
|
|
14
|
+
and a mismatch with the last emission re-teaches the spec in full.
|
|
15
|
+
|
|
16
|
+
Budget
|
|
17
|
+
All caps are in characters, because the platform's ``additionalContext``
|
|
18
|
+
ceiling is 10,000 *characters* (counting bytes made CJK specs pay 3x).
|
|
19
|
+
Truncation slices code points, which can never split a multi-byte
|
|
20
|
+
sequence. The per-event cap is enforced on the assembled payload string —
|
|
21
|
+
wrappers, ``\\n\\n`` separators, index block and tickets all counted — so
|
|
22
|
+
nothing is ever appended unchecked.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import sys
|
|
29
|
+
from typing import Any, Sequence
|
|
30
|
+
|
|
31
|
+
from .spec_match import SpecMatch
|
|
32
|
+
|
|
33
|
+
# Bound on the size of a spec file we are willing to read and hash. A spec
|
|
34
|
+
# larger than this degrades to an index line (warned) — an inlined body that
|
|
35
|
+
# big could never fit the budget anyway, and the read+hash would be unbounded.
|
|
36
|
+
MAX_SPEC_SOURCE_BYTES = 10 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
# Upper bound on the room reserved for named index lines while FULL blocks are
|
|
39
|
+
# still being packed. Beyond it the reserve falls back to the summary line
|
|
40
|
+
# alone: a big fan-out must not starve the specs that can still be taught.
|
|
41
|
+
INDEX_RESERVE_MAX_CHARS = 900
|
|
42
|
+
|
|
43
|
+
# State record schema version. Records with any other version are ignored
|
|
44
|
+
# (safe direction: an ignored record re-injects rather than stays silent).
|
|
45
|
+
STATE_VERSION = 2
|
|
46
|
+
|
|
47
|
+
def _warn(message: str) -> None:
|
|
48
|
+
print(f"[WARN] spec_inject: {message}", file=sys.stderr)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# Clock
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def within_window(
|
|
57
|
+
clock: dict[str, Any],
|
|
58
|
+
last: dict[str, Any],
|
|
59
|
+
win_seconds: int,
|
|
60
|
+
) -> bool:
|
|
61
|
+
"""True when the last emission is still inside the refresh window (→ stay
|
|
62
|
+
silent).
|
|
63
|
+
|
|
64
|
+
A window of ``0`` means never refresh (infinite window → always True).
|
|
65
|
+
Missing timestamps or a negative delta are past-window (False → refresh),
|
|
66
|
+
the safe side of the misfire asymmetry.
|
|
67
|
+
"""
|
|
68
|
+
cur_ts = clock.get("ts")
|
|
69
|
+
last_ts = last.get("ts")
|
|
70
|
+
if isinstance(cur_ts, (int, float)) and isinstance(last_ts, (int, float)):
|
|
71
|
+
if win_seconds == 0:
|
|
72
|
+
return True
|
|
73
|
+
delta = cur_ts - last_ts
|
|
74
|
+
return 0 <= delta < win_seconds
|
|
75
|
+
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decide(
|
|
80
|
+
stateless: bool,
|
|
81
|
+
last: dict[str, Any] | None,
|
|
82
|
+
sha256_hex: str,
|
|
83
|
+
clock: dict[str, Any],
|
|
84
|
+
win_seconds: int,
|
|
85
|
+
) -> str:
|
|
86
|
+
"""Return one of ``"full"`` | ``"ticket"`` | ``"silent"`` for a spec.
|
|
87
|
+
|
|
88
|
+
Order is contractual: statelessness first (bounded cost, no state to
|
|
89
|
+
consult), then first sight, content change, context reset, the refresh
|
|
90
|
+
window, and finally completeness. A ticket says "you were shown this spec",
|
|
91
|
+
which is a lie when the recorded FULL was truncated, so an incomplete
|
|
92
|
+
record is re-taught in full instead.
|
|
93
|
+
"""
|
|
94
|
+
if stateless:
|
|
95
|
+
return "ticket"
|
|
96
|
+
if last is None:
|
|
97
|
+
return "full"
|
|
98
|
+
if last.get("sha256") != sha256_hex:
|
|
99
|
+
return "full"
|
|
100
|
+
if clock.get("reset") != last.get("reset"):
|
|
101
|
+
return "full"
|
|
102
|
+
|
|
103
|
+
if within_window(clock, last, win_seconds):
|
|
104
|
+
return "silent"
|
|
105
|
+
|
|
106
|
+
# "complete" is optional and absent means a whole body was shown.
|
|
107
|
+
if last.get("complete") is False:
|
|
108
|
+
return "full"
|
|
109
|
+
return "ticket"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# =============================================================================
|
|
113
|
+
# Rendering
|
|
114
|
+
# =============================================================================
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def truncate_chars(text: str, cap: int) -> str:
|
|
118
|
+
"""Slice ``text`` to at most ``cap`` code points. ``cap <= 0`` = no limit."""
|
|
119
|
+
if cap <= 0 or len(text) <= cap:
|
|
120
|
+
return text
|
|
121
|
+
return text[:cap]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def truncation_notice(rel_path: str, cap: int) -> str:
|
|
125
|
+
return (
|
|
126
|
+
f"\n[Trellis: truncated at {cap} characters — "
|
|
127
|
+
f"read {rel_path} for the full content]"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render_full(edited_rel: str, spec_rel: str, sha12: str, body: str) -> str:
|
|
132
|
+
return (
|
|
133
|
+
f'<spec-context file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
|
|
134
|
+
f"{body}\n"
|
|
135
|
+
f"</spec-context>"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def render_ticket(
|
|
140
|
+
edited_rel: str,
|
|
141
|
+
spec_rel: str,
|
|
142
|
+
sha12: str,
|
|
143
|
+
stateless: bool,
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Render a ticket block.
|
|
146
|
+
|
|
147
|
+
``stateless=True`` covers both the no-identity and circuit-breaker paths:
|
|
148
|
+
there is no record of a prior emission, so the wording must not claim one.
|
|
149
|
+
"""
|
|
150
|
+
if stateless:
|
|
151
|
+
body = (
|
|
152
|
+
"This spec governs the file you just touched. If you have not read it in\n"
|
|
153
|
+
f"this session, Read {spec_rel} before continuing."
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
body = (
|
|
157
|
+
"You were shown this spec earlier in this session and its content is unchanged.\n"
|
|
158
|
+
"It still governs edits to matching files. If you no longer remember it, Read\n"
|
|
159
|
+
f"{spec_rel} before continuing."
|
|
160
|
+
)
|
|
161
|
+
return (
|
|
162
|
+
f'<spec-ticket file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
|
|
163
|
+
f"{body}\n"
|
|
164
|
+
f"</spec-ticket>"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _index_block(lines: Sequence[str]) -> str:
|
|
169
|
+
return "<spec-index>\n" + "\n".join(lines) + "\n</spec-index>"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# =============================================================================
|
|
173
|
+
# State records
|
|
174
|
+
# =============================================================================
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def make_record(
|
|
178
|
+
rel_path: str,
|
|
179
|
+
sha256_hex: str,
|
|
180
|
+
mode: str,
|
|
181
|
+
clock: dict[str, Any],
|
|
182
|
+
complete: bool = True,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
"""Build a state record. ``complete=False`` marks a FULL whose body was
|
|
185
|
+
truncated below the whole spec — an absent flag means whole."""
|
|
186
|
+
record: dict[str, Any] = {
|
|
187
|
+
"v": STATE_VERSION,
|
|
188
|
+
"spec": rel_path,
|
|
189
|
+
"sha256": sha256_hex,
|
|
190
|
+
"mode": mode,
|
|
191
|
+
"ts": clock.get("ts"),
|
|
192
|
+
}
|
|
193
|
+
if isinstance(clock.get("reset"), str):
|
|
194
|
+
record["reset"] = clock["reset"]
|
|
195
|
+
if not complete:
|
|
196
|
+
record["complete"] = False
|
|
197
|
+
return record
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# =============================================================================
|
|
201
|
+
# Payload assembly
|
|
202
|
+
# =============================================================================
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _derive_fitting_full(
|
|
206
|
+
edited_rel: str,
|
|
207
|
+
spec_rel: str,
|
|
208
|
+
sha12: str,
|
|
209
|
+
text: str,
|
|
210
|
+
max_spec_chars: int,
|
|
211
|
+
fits,
|
|
212
|
+
) -> tuple[str, bool] | None:
|
|
213
|
+
"""Largest truncated FULL block that fits the remaining total budget.
|
|
214
|
+
|
|
215
|
+
Binary search over the body cap: the rendered block's length is monotone
|
|
216
|
+
non-decreasing in the cap, so the largest cap whose block still ``fits``
|
|
217
|
+
is found in ~log2(len(text)) renders (this also absorbs the digit-length
|
|
218
|
+
wobble of the notice text, which a closed-form estimate cannot).
|
|
219
|
+
|
|
220
|
+
The search ceiling is ``max_spec_chars`` when set and the whole body when
|
|
221
|
+
it is ``0`` (unlimited) — with a ceiling of 1, as an unguarded
|
|
222
|
+
``max(1, 0)`` would give, nothing but a one-character spec could ever be
|
|
223
|
+
derived. Returns ``(block, complete)`` — ``complete`` is True only when
|
|
224
|
+
the winning cap covered the whole body — or None when no non-empty prefix
|
|
225
|
+
fits (the caller degrades to an index line).
|
|
226
|
+
"""
|
|
227
|
+
def candidate_for(cap: int) -> str:
|
|
228
|
+
body = truncate_chars(text, cap)
|
|
229
|
+
if len(body) < len(text):
|
|
230
|
+
body += truncation_notice(spec_rel, cap)
|
|
231
|
+
return render_full(edited_rel, spec_rel, sha12, body)
|
|
232
|
+
|
|
233
|
+
ceiling = len(text) if max_spec_chars <= 0 else min(max_spec_chars, len(text))
|
|
234
|
+
lo, hi = 1, max(1, ceiling)
|
|
235
|
+
best: tuple[str, bool] | None = None
|
|
236
|
+
while lo <= hi:
|
|
237
|
+
mid = (lo + hi) // 2
|
|
238
|
+
candidate = candidate_for(mid)
|
|
239
|
+
if fits(candidate):
|
|
240
|
+
best = (candidate, mid >= len(text))
|
|
241
|
+
lo = mid + 1
|
|
242
|
+
else:
|
|
243
|
+
hi = mid - 1
|
|
244
|
+
return best
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _index_line(match: SpecMatch) -> str:
|
|
248
|
+
return f"- {match.rel_path} — {match.description or 'no description'}"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def assemble_payload(
|
|
252
|
+
edited_rel: str,
|
|
253
|
+
matches: Sequence[SpecMatch],
|
|
254
|
+
stateless: bool,
|
|
255
|
+
state_records: dict[str, dict[str, Any]],
|
|
256
|
+
clock: dict[str, Any],
|
|
257
|
+
max_spec_chars: int,
|
|
258
|
+
max_total_chars: int,
|
|
259
|
+
win_seconds: int,
|
|
260
|
+
match_files: dict[str, str] | None = None,
|
|
261
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
262
|
+
"""Assemble the additionalContext payload from the matched specs.
|
|
263
|
+
|
|
264
|
+
Returns ``(payload, records)`` where ``records`` are the state lines to
|
|
265
|
+
append for the emissions that actually made it into the payload (silent
|
|
266
|
+
hits and budget-dropped emissions record nothing — they stay eligible).
|
|
267
|
+
|
|
268
|
+
Every candidate block is measured against the *assembled* payload string
|
|
269
|
+
(``"\\n\\n".join(...)``), so the per-event character ceiling holds for the
|
|
270
|
+
exact string that is emitted.
|
|
271
|
+
|
|
272
|
+
``match_files`` maps a governing spec to the first matching file in a
|
|
273
|
+
multi-file tool call. Single-file callers omit it and retain the original
|
|
274
|
+
``edited_rel`` behavior.
|
|
275
|
+
"""
|
|
276
|
+
blocks: list[str] = []
|
|
277
|
+
|
|
278
|
+
def file_for(match: SpecMatch) -> str:
|
|
279
|
+
return (match_files or {}).get(match.rel_path, edited_rel)
|
|
280
|
+
|
|
281
|
+
def fits(candidate: str, reserve: int = 0) -> bool:
|
|
282
|
+
"""Does ``candidate`` fit the per-event ceiling, keeping ``reserve``
|
|
283
|
+
characters free for what still has to be appended after it?"""
|
|
284
|
+
if max_total_chars <= 0:
|
|
285
|
+
return True
|
|
286
|
+
return len("\n\n".join([*blocks, candidate])) + reserve <= max_total_chars
|
|
287
|
+
|
|
288
|
+
# Reserve while candidates are still pending: the index lines those
|
|
289
|
+
# candidates would actually need (true strings, not estimates) plus the
|
|
290
|
+
# summary line — so a derived-cap FULL cannot eat the budget and starve
|
|
291
|
+
# the specs behind it (measured: 10-spec fan-out at max_total_chars 3000
|
|
292
|
+
# emitted one 3000-char FULL and dropped the other nine silently). The
|
|
293
|
+
# named part is only guaranteed within INDEX_RESERVE_MAX_CHARS; beyond
|
|
294
|
+
# that the reserve falls back to the summary line alone.
|
|
295
|
+
_all_index_lines = [_index_line(m) for m in matches]
|
|
296
|
+
_summary_upper = (
|
|
297
|
+
f"- (+{len(matches)} more governing specs over budget — run "
|
|
298
|
+
f"python3 ./.trellis/scripts/get_context.py --mode spec "
|
|
299
|
+
f"--file {edited_rel} to list them)"
|
|
300
|
+
)
|
|
301
|
+
_summary_reserve = len("\n\n" + _index_block([_summary_upper]))
|
|
302
|
+
|
|
303
|
+
def reserve_for(pending: Sequence[str]) -> int:
|
|
304
|
+
if not pending:
|
|
305
|
+
return 0 # Nothing can follow this block — nothing to reserve.
|
|
306
|
+
named = len("\n\n" + _index_block([*pending, _summary_upper]))
|
|
307
|
+
if named > INDEX_RESERVE_MAX_CHARS:
|
|
308
|
+
return _summary_reserve
|
|
309
|
+
return named
|
|
310
|
+
|
|
311
|
+
index_lines: list[str] = []
|
|
312
|
+
ticket_pending: list[tuple[str, str, str]] = [] # (file, spec, sha256)
|
|
313
|
+
records: list[dict[str, Any]] = []
|
|
314
|
+
|
|
315
|
+
for match_idx, match in enumerate(matches):
|
|
316
|
+
try:
|
|
317
|
+
size = match.spec_path.stat().st_size
|
|
318
|
+
except OSError:
|
|
319
|
+
size = 0
|
|
320
|
+
if size > MAX_SPEC_SOURCE_BYTES:
|
|
321
|
+
# Too big to read+hash, let alone inline: name it and move on.
|
|
322
|
+
_warn(
|
|
323
|
+
f"{match.rel_path} is {size} bytes (over "
|
|
324
|
+
f"{MAX_SPEC_SOURCE_BYTES}) — degraded to an index line"
|
|
325
|
+
)
|
|
326
|
+
index_lines.append(_index_line(match))
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
data = match.spec_path.read_bytes()
|
|
331
|
+
except OSError:
|
|
332
|
+
_warn(f"cannot read {match.rel_path} — skipped")
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
sha256_hex = hashlib.sha256(data).hexdigest()
|
|
336
|
+
sha12 = sha256_hex[:12]
|
|
337
|
+
last = None if stateless else state_records.get(match.rel_path)
|
|
338
|
+
decision = decide(stateless, last, sha256_hex, clock, win_seconds)
|
|
339
|
+
|
|
340
|
+
if decision == "silent":
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if decision == "ticket":
|
|
344
|
+
# Deferred: tickets are counted against the budget last.
|
|
345
|
+
ticket_pending.append((file_for(match), match.rel_path, sha256_hex))
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
pending = [*index_lines, *_all_index_lines[match_idx + 1 :]]
|
|
349
|
+
reserve = reserve_for(pending)
|
|
350
|
+
_fits = (lambda c: fits(c, reserve))
|
|
351
|
+
|
|
352
|
+
text = data.decode("utf-8", errors="replace")
|
|
353
|
+
body = truncate_chars(text, max_spec_chars)
|
|
354
|
+
complete = len(body) >= len(text)
|
|
355
|
+
if not complete:
|
|
356
|
+
body += truncation_notice(match.rel_path, max_spec_chars)
|
|
357
|
+
matching_file = file_for(match)
|
|
358
|
+
block = render_full(matching_file, match.rel_path, sha12, body)
|
|
359
|
+
if not _fits(block):
|
|
360
|
+
# Contract amendment 1: before degrading, truncate FURTHER to the
|
|
361
|
+
# largest body prefix that fits the remaining total budget
|
|
362
|
+
# (wrapper + notice counted). Without this, the frozen defaults
|
|
363
|
+
# made the truncation path unreachable (body cap + notice +
|
|
364
|
+
# wrapper > total cap) and long specs fell straight to an index
|
|
365
|
+
# line — the rejected index-only mode by another route.
|
|
366
|
+
derived = _derive_fitting_full(
|
|
367
|
+
matching_file,
|
|
368
|
+
match.rel_path,
|
|
369
|
+
sha12,
|
|
370
|
+
text,
|
|
371
|
+
max_spec_chars,
|
|
372
|
+
_fits,
|
|
373
|
+
)
|
|
374
|
+
if derived is not None:
|
|
375
|
+
derived_block, derived_complete = derived
|
|
376
|
+
blocks.append(derived_block)
|
|
377
|
+
records.append(
|
|
378
|
+
make_record(
|
|
379
|
+
match.rel_path, sha256_hex, "full", clock, derived_complete
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
continue
|
|
383
|
+
# No usable prefix fits — degrade to an index line, never drop
|
|
384
|
+
# silently. Not recorded: stays eligible for a later event.
|
|
385
|
+
index_lines.append(_index_line(match))
|
|
386
|
+
continue
|
|
387
|
+
blocks.append(block)
|
|
388
|
+
records.append(
|
|
389
|
+
make_record(match.rel_path, sha256_hex, "full", clock, complete)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if index_lines:
|
|
393
|
+
# The index block is budget-bounded too: lines that do not fit collapse
|
|
394
|
+
# into one summary line (count + how to list them via pull mode) so the
|
|
395
|
+
# ceiling is honored without silently dropping a governing spec.
|
|
396
|
+
chosen: list[str] = []
|
|
397
|
+
dropped = 0
|
|
398
|
+
for line in index_lines:
|
|
399
|
+
if fits(_index_block([*chosen, line])):
|
|
400
|
+
chosen.append(line)
|
|
401
|
+
else:
|
|
402
|
+
dropped += 1
|
|
403
|
+
if dropped:
|
|
404
|
+
# Contract amendment 3: the summary must actually be reachable.
|
|
405
|
+
# Greedy packing rarely leaves a summary-sized gap, so pop chosen
|
|
406
|
+
# lines (re-counting them as dropped) until the summary fits —
|
|
407
|
+
# only an absurdly small total budget can drop it entirely.
|
|
408
|
+
while True:
|
|
409
|
+
noun = "spec" if dropped == 1 else "specs"
|
|
410
|
+
summary = (
|
|
411
|
+
f"- (+{dropped} more governing {noun} over budget — run "
|
|
412
|
+
f"python3 ./.trellis/scripts/get_context.py --mode spec "
|
|
413
|
+
f"--file {edited_rel} to list them)"
|
|
414
|
+
)
|
|
415
|
+
if fits(_index_block([*chosen, summary])):
|
|
416
|
+
chosen.append(summary)
|
|
417
|
+
break
|
|
418
|
+
if not chosen:
|
|
419
|
+
_warn(
|
|
420
|
+
f"spec index summary for {edited_rel} dropped — "
|
|
421
|
+
f"per-event budget exhausted"
|
|
422
|
+
)
|
|
423
|
+
break
|
|
424
|
+
chosen.pop()
|
|
425
|
+
dropped += 1
|
|
426
|
+
if chosen:
|
|
427
|
+
blocks.append(_index_block(chosen))
|
|
428
|
+
|
|
429
|
+
for matching_file, spec_rel, sha256_hex in ticket_pending:
|
|
430
|
+
ticket = render_ticket(
|
|
431
|
+
matching_file, spec_rel, sha256_hex[:12], stateless
|
|
432
|
+
)
|
|
433
|
+
if not fits(ticket):
|
|
434
|
+
_warn(f"ticket for {spec_rel} dropped — per-event budget exhausted")
|
|
435
|
+
continue
|
|
436
|
+
blocks.append(ticket)
|
|
437
|
+
records.append(make_record(spec_rel, sha256_hex, "ticket", clock))
|
|
438
|
+
|
|
439
|
+
return "\n\n".join(blocks), records
|