@pennixrv/trellis 0.6.32 → 0.6.34
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/migrations/manifests/0.6.32.json +9 -0
- package/dist/templates/shared-hooks/inject-subagent-context.py +66 -443
- package/dist/templates/trellis/index.d.ts +1 -0
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +2 -0
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/context_projection.py +508 -0
- package/dist/templates/trellis/scripts/common/task_context.py +34 -160
- package/package.json +2 -2
|
@@ -26,10 +26,11 @@ import json
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
|
|
28
28
|
from .config import get_context_injection_limits
|
|
29
|
+
from .context_projection import parse_context_manifest, project_agent_context
|
|
29
30
|
from .git import branch_exists_locally
|
|
30
31
|
from .io import read_json
|
|
31
32
|
from .log import Colors, colored
|
|
32
|
-
from .paths import
|
|
33
|
+
from .paths import FILE_TASK_JSON, get_repo_root
|
|
33
34
|
from .task_utils import resolve_task_dir
|
|
34
35
|
|
|
35
36
|
# Extensions that look like code rather than spec/research docs. Entries with
|
|
@@ -134,24 +135,8 @@ def curated_entry_count(jsonl_file: Path) -> int | None:
|
|
|
134
135
|
entry is a JSON object row carrying a truthy ``file`` (or legacy ``path``)
|
|
135
136
|
value: the same rows the sub-agent injection hook materializes.
|
|
136
137
|
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
try:
|
|
140
|
-
lines = jsonl_file.read_text(encoding="utf-8").splitlines()
|
|
141
|
-
except OSError:
|
|
142
|
-
return 0
|
|
143
|
-
count = 0
|
|
144
|
-
for line in lines:
|
|
145
|
-
line = line.strip()
|
|
146
|
-
if not line:
|
|
147
|
-
continue
|
|
148
|
-
try:
|
|
149
|
-
data = json.loads(line)
|
|
150
|
-
except json.JSONDecodeError:
|
|
151
|
-
continue
|
|
152
|
-
if isinstance(data, dict) and (data.get("file") or data.get("path")):
|
|
153
|
-
count += 1
|
|
154
|
-
return count
|
|
138
|
+
entries, _, manifest_exists = parse_context_manifest(jsonl_file)
|
|
139
|
+
return len(entries) if manifest_exists else None
|
|
155
140
|
|
|
156
141
|
|
|
157
142
|
def cmd_validate(args: argparse.Namespace) -> int:
|
|
@@ -214,59 +199,6 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
|
|
|
214
199
|
return False
|
|
215
200
|
|
|
216
201
|
|
|
217
|
-
def _resolve_context_entry_path(
|
|
218
|
-
file_path: str, repo_root: Path, task_dir: Path | None
|
|
219
|
-
) -> Path | None:
|
|
220
|
-
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
|
|
221
|
-
|
|
222
|
-
Exact historical self-references are remapped only for archived tasks.
|
|
223
|
-
``None`` means the remapped path traversed or resolved outside that archive.
|
|
224
|
-
"""
|
|
225
|
-
repo_path = repo_root / file_path
|
|
226
|
-
if task_dir is None:
|
|
227
|
-
return repo_path
|
|
228
|
-
|
|
229
|
-
try:
|
|
230
|
-
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
|
|
231
|
-
except ValueError:
|
|
232
|
-
return repo_path
|
|
233
|
-
|
|
234
|
-
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
|
|
235
|
-
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
|
|
236
|
-
return repo_path
|
|
237
|
-
|
|
238
|
-
year_month = task_parts[3]
|
|
239
|
-
if (
|
|
240
|
-
len(year_month) != 7
|
|
241
|
-
or year_month[4] != "-"
|
|
242
|
-
or not year_month[:4].isdigit()
|
|
243
|
-
or not year_month[5:].isdigit()
|
|
244
|
-
):
|
|
245
|
-
return repo_path
|
|
246
|
-
|
|
247
|
-
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
|
|
248
|
-
posix_path = file_path.replace("\\", "/")
|
|
249
|
-
if posix_path == historical_root:
|
|
250
|
-
relative_parts: tuple[str, ...] = ()
|
|
251
|
-
elif posix_path.startswith(f"{historical_root}/"):
|
|
252
|
-
relative_path = posix_path[len(historical_root) + 1 :]
|
|
253
|
-
if relative_path.endswith("/"):
|
|
254
|
-
relative_path = relative_path[:-1]
|
|
255
|
-
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
|
|
256
|
-
if any(part in ("", ".", "..") for part in relative_parts):
|
|
257
|
-
return None
|
|
258
|
-
else:
|
|
259
|
-
return repo_path
|
|
260
|
-
|
|
261
|
-
try:
|
|
262
|
-
archive_root = task_dir.resolve()
|
|
263
|
-
resolved_path = task_dir.joinpath(*relative_parts).resolve()
|
|
264
|
-
resolved_path.relative_to(archive_root)
|
|
265
|
-
except (OSError, RuntimeError, ValueError):
|
|
266
|
-
return None
|
|
267
|
-
return resolved_path
|
|
268
|
-
|
|
269
|
-
|
|
270
202
|
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
|
|
271
203
|
"""Validate a single JSONL file.
|
|
272
204
|
|
|
@@ -275,11 +207,10 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|
|
275
207
|
accepting them here would pass locally and fail later. Other rows without
|
|
276
208
|
a ``file`` field are skipped silently, matching what consumers do.
|
|
277
209
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
sub-agent context injection cap (``context_injection.max_file_bytes``).
|
|
210
|
+
The same Python projection that the shared Hook renders is evaluated for
|
|
211
|
+
each role. Hygiene warnings remain advisory; every source that would be
|
|
212
|
+
truncated, omitted, or indexed instead of appearing as complete body text
|
|
213
|
+
is a hard validation error.
|
|
283
214
|
"""
|
|
284
215
|
file_name = jsonl_file.name
|
|
285
216
|
errors = 0
|
|
@@ -295,97 +226,26 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|
|
295
226
|
except ValueError:
|
|
296
227
|
task_rel = ""
|
|
297
228
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
for line in jsonl_file.read_text(encoding="utf-8").splitlines():
|
|
303
|
-
line_num += 1
|
|
304
|
-
if not line.strip():
|
|
305
|
-
continue
|
|
306
|
-
|
|
307
|
-
try:
|
|
308
|
-
data = json.loads(line)
|
|
309
|
-
except json.JSONDecodeError:
|
|
310
|
-
print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}")
|
|
311
|
-
errors += 1
|
|
312
|
-
continue
|
|
313
|
-
|
|
314
|
-
if not isinstance(data, dict):
|
|
315
|
-
print(
|
|
316
|
-
f" {colored(f'{file_name}:{line_num}: Expected a JSON object', Colors.RED)}"
|
|
317
|
-
)
|
|
318
|
-
errors += 1
|
|
319
|
-
continue
|
|
320
|
-
|
|
321
|
-
if "_example" in data:
|
|
322
|
-
error_message = (
|
|
323
|
-
f"{file_name}:{line_num}: Placeholder `_example` row left by an older "
|
|
324
|
-
"task.py create — delete this line, or replace it with "
|
|
325
|
-
'{"file": "<path>", "reason": "<why>"}'
|
|
326
|
-
)
|
|
327
|
-
print(f" {colored(error_message, Colors.RED)}")
|
|
328
|
-
errors += 1
|
|
329
|
-
continue
|
|
330
|
-
|
|
331
|
-
file_path = data.get("file")
|
|
332
|
-
entry_type = data.get("type", "file")
|
|
333
|
-
|
|
334
|
-
if not file_path:
|
|
335
|
-
# Comment / unknown row without a path — skip silently
|
|
336
|
-
continue
|
|
337
|
-
|
|
338
|
-
if not isinstance(file_path, str):
|
|
339
|
-
# A truthy non-string (e.g. {"file": 1}) reached path joining and
|
|
340
|
-
# raised TypeError, so validation crashed on the row it exists to
|
|
341
|
-
# report.
|
|
342
|
-
print(
|
|
343
|
-
f" {colored(f'{file_name}:{line_num}: `file` must be a string path', Colors.RED)}"
|
|
344
|
-
)
|
|
345
|
-
errors += 1
|
|
346
|
-
continue
|
|
229
|
+
entries, manifest_problems, _ = parse_context_manifest(jsonl_file)
|
|
230
|
+
for problem in manifest_problems:
|
|
231
|
+
print(f" {colored(f'{file_name}:{problem.line}: {problem.message}', Colors.RED)}")
|
|
232
|
+
errors += 1
|
|
347
233
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if entry_type == "directory":
|
|
351
|
-
if full_path is None or not full_path.is_dir():
|
|
352
|
-
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
|
|
353
|
-
errors += 1
|
|
234
|
+
for entry in entries:
|
|
235
|
+
if entry.entry_type == "directory":
|
|
354
236
|
continue
|
|
355
|
-
|
|
356
|
-
if full_path is None or not full_path.is_file():
|
|
357
|
-
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
|
|
358
|
-
errors += 1
|
|
359
|
-
continue
|
|
360
|
-
|
|
361
|
-
extension = Path(file_path).suffix.lower()
|
|
237
|
+
extension = Path(entry.path).suffix.lower()
|
|
362
238
|
if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
|
|
363
|
-
|
|
239
|
+
entry.path, task_rel
|
|
364
240
|
):
|
|
365
241
|
warning_message = (
|
|
366
|
-
f"{file_name}:{
|
|
242
|
+
f"{file_name}:{entry.line}: Warning: {entry.path} looks like a code file — "
|
|
367
243
|
"implement/check.jsonl should reference spec/research docs; "
|
|
368
244
|
"agents read code themselves"
|
|
369
245
|
)
|
|
370
246
|
print(f" {colored(warning_message, Colors.YELLOW)}")
|
|
371
247
|
|
|
372
|
-
|
|
373
|
-
# Advisory hygiene warning, so it must never be what fails
|
|
374
|
-
# `validate`. `stat()` can still raise after the `is_file()` check
|
|
375
|
-
# above — a permission change, or the file removed in between.
|
|
376
|
-
try:
|
|
377
|
-
size: int | None = full_path.stat().st_size
|
|
378
|
-
except OSError:
|
|
379
|
-
size = None
|
|
380
|
-
if size is not None and size > max_file_bytes:
|
|
381
|
-
warning_message = (
|
|
382
|
-
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
|
|
383
|
-
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
|
|
384
|
-
"injection will truncate it"
|
|
385
|
-
)
|
|
386
|
-
print(f" {colored(warning_message, Colors.YELLOW)}")
|
|
387
|
-
|
|
388
|
-
if errors == 0 and real_entries == 0:
|
|
248
|
+
if errors == 0 and not entries:
|
|
389
249
|
# Seed-only / empty manifest: sub-agents dispatched for this task
|
|
390
250
|
# would run with zero spec context (#573). Silent-green here is how
|
|
391
251
|
# more than half the tasks in the report ended up uncurated.
|
|
@@ -401,8 +261,22 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
|
|
401
261
|
)
|
|
402
262
|
return 1
|
|
403
263
|
|
|
264
|
+
if errors == 0 and task_dir is not None:
|
|
265
|
+
role = jsonl_file.stem
|
|
266
|
+
projection = project_agent_context(
|
|
267
|
+
repo_root, task_dir, role, get_context_injection_limits(repo_root)
|
|
268
|
+
)
|
|
269
|
+
for issue in projection.issues:
|
|
270
|
+
line_label = str(issue.line) if issue.line is not None else "artifact"
|
|
271
|
+
message = (
|
|
272
|
+
f"{file_name}:{line_label}: role={issue.role} source={issue.category} "
|
|
273
|
+
f"path={issue.path}: cannot be injected in full — {issue.reason}"
|
|
274
|
+
)
|
|
275
|
+
print(f" {colored(message, Colors.RED)}")
|
|
276
|
+
errors += 1
|
|
277
|
+
|
|
404
278
|
if errors == 0:
|
|
405
|
-
print(f" {colored(f'{file_name}: ✓ ({
|
|
279
|
+
print(f" {colored(f'{file_name}: ✓ ({len(entries)} entries)', Colors.GREEN)}")
|
|
406
280
|
else:
|
|
407
281
|
print(f" {colored(f'{file_name}: ✗ ({errors} errors)', Colors.RED)}")
|
|
408
282
|
|
|
@@ -446,7 +320,7 @@ def cmd_list_context(args: argparse.Namespace) -> int:
|
|
|
446
320
|
if not isinstance(data, dict):
|
|
447
321
|
continue
|
|
448
322
|
|
|
449
|
-
file_path = data.get("file")
|
|
323
|
+
file_path = data.get("file") or data.get("path")
|
|
450
324
|
if not file_path:
|
|
451
325
|
# Placeholder / comment row — don't count as a real entry
|
|
452
326
|
continue
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pennixrv/trellis",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.34",
|
|
4
4
|
"description": "AI capabilities grow like ivy — Trellis provides the structure to guide them along a disciplined path",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"inquirer": "^9.3.7",
|
|
35
35
|
"undici": "^6.21.0",
|
|
36
36
|
"zod": "^4.4.2",
|
|
37
|
-
"@pennixrv/trellis-core": "0.6.
|
|
37
|
+
"@pennixrv/trellis-core": "0.6.34"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@eslint/js": "^9.18.0",
|