@mindfoldhq/trellis 0.6.10 → 0.7.0-beta.0

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 (48) hide show
  1. package/dist/cli/index.js +2 -0
  2. package/dist/cli/index.js.map +1 -1
  3. package/dist/commands/update.d.ts.map +1 -1
  4. package/dist/commands/update.js +0 -8
  5. package/dist/commands/update.js.map +1 -1
  6. package/dist/commands/workflow.d.ts +6 -0
  7. package/dist/commands/workflow.d.ts.map +1 -1
  8. package/dist/commands/workflow.js +124 -0
  9. package/dist/commands/workflow.js.map +1 -1
  10. package/dist/configurators/workflow.d.ts +0 -14
  11. package/dist/configurators/workflow.d.ts.map +1 -1
  12. package/dist/configurators/workflow.js +1 -37
  13. package/dist/configurators/workflow.js.map +1 -1
  14. package/dist/migrations/manifests/0.7.0-beta.0.json +9 -0
  15. package/dist/templates/claude/settings.json +22 -0
  16. package/dist/templates/codex/agents/trellis-check.toml +2 -2
  17. package/dist/templates/codex/agents/trellis-implement.toml +2 -2
  18. package/dist/templates/codex/agents/trellis-research.toml +2 -2
  19. package/dist/templates/codex/hooks/session-start.py +20 -1
  20. package/dist/templates/codex/hooks.json +25 -0
  21. package/dist/templates/copilot/hooks/session-start.py +20 -1
  22. package/dist/templates/opencode/plugins/inject-workflow-state.js +111 -10
  23. package/dist/templates/pi/extensions/trellis/index.ts.txt +4 -3
  24. package/dist/templates/shared-hooks/index.d.ts +5 -1
  25. package/dist/templates/shared-hooks/index.d.ts.map +1 -1
  26. package/dist/templates/shared-hooks/index.js +10 -1
  27. package/dist/templates/shared-hooks/index.js.map +1 -1
  28. package/dist/templates/shared-hooks/inject-spec-context.py +840 -0
  29. package/dist/templates/shared-hooks/inject-subagent-context.py +4 -3
  30. package/dist/templates/shared-hooks/inject-workflow-state.py +35 -9
  31. package/dist/templates/shared-hooks/session-start.py +22 -1
  32. package/dist/templates/trellis/config.yaml +47 -0
  33. package/dist/templates/trellis/index.d.ts +4 -3
  34. package/dist/templates/trellis/index.d.ts.map +1 -1
  35. package/dist/templates/trellis/index.js +7 -3
  36. package/dist/templates/trellis/index.js.map +1 -1
  37. package/dist/templates/trellis/scripts/add_session.py +0 -45
  38. package/dist/templates/trellis/scripts/common/config.py +18 -0
  39. package/dist/templates/trellis/scripts/common/git_context.py +34 -2
  40. package/dist/templates/trellis/scripts/common/paths.py +28 -0
  41. package/dist/templates/trellis/scripts/common/spec_inject.py +439 -0
  42. package/dist/templates/trellis/scripts/common/spec_match.py +395 -0
  43. package/dist/templates/trellis/scripts/common/task_store.py +30 -0
  44. package/dist/templates/trellis/scripts/common/workflow_phase.py +3 -2
  45. package/dist/templates/trellis/scripts/common/workflow_selection.py +177 -0
  46. package/dist/templates/trellis/scripts/task.py +82 -0
  47. package/package.json +2 -2
  48. 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