@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.
@@ -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 DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
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
- if not jsonl_file.is_file():
138
- return None
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
- Beyond hard errors (missing file/dir, invalid JSON), this also prints
279
- non-blocking hygiene warnings (never counted in ``errors``, never change
280
- the exit code): entries that look like code files rather than
281
- spec/research docs, and entries whose file size exceeds the configured
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
- max_file_bytes = get_context_injection_limits(repo_root).get("max_file_bytes", 0)
299
-
300
- line_num = 0
301
- real_entries = 0
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
- real_entries += 1
349
- full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
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
- file_path, task_rel
239
+ entry.path, task_rel
364
240
  ):
365
241
  warning_message = (
366
- f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
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
- if max_file_bytes:
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}: ✓ ({real_entries} entries)', Colors.GREEN)}")
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.32",
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.32"
37
+ "@pennixrv/trellis-core": "0.6.34"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@eslint/js": "^9.18.0",