@pennixrv/trellis 0.7.0-beta.10 → 0.7.0-beta.11

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 (31) hide show
  1. package/dist/commands/channel/adapters/codex.d.ts +2 -1
  2. package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
  3. package/dist/commands/channel/adapters/codex.js +10 -7
  4. package/dist/commands/channel/adapters/codex.js.map +1 -1
  5. package/dist/commands/channel/adapters/index.d.ts +2 -0
  6. package/dist/commands/channel/adapters/index.d.ts.map +1 -1
  7. package/dist/commands/channel/adapters/index.js +1 -1
  8. package/dist/commands/channel/adapters/index.js.map +1 -1
  9. package/dist/commands/channel/index.d.ts.map +1 -1
  10. package/dist/commands/channel/index.js +6 -0
  11. package/dist/commands/channel/index.js.map +1 -1
  12. package/dist/commands/channel/profiles.d.ts +26 -0
  13. package/dist/commands/channel/profiles.d.ts.map +1 -0
  14. package/dist/commands/channel/profiles.js +186 -0
  15. package/dist/commands/channel/profiles.js.map +1 -0
  16. package/dist/commands/channel/spawn.d.ts +3 -0
  17. package/dist/commands/channel/spawn.d.ts.map +1 -1
  18. package/dist/commands/channel/spawn.js +22 -3
  19. package/dist/commands/channel/spawn.js.map +1 -1
  20. package/dist/commands/channel/supervisor.d.ts +8 -0
  21. package/dist/commands/channel/supervisor.d.ts.map +1 -1
  22. package/dist/commands/channel/supervisor.js +19 -0
  23. package/dist/commands/channel/supervisor.js.map +1 -1
  24. package/dist/migrations/manifests/0.7.0-beta.11.json +9 -0
  25. package/dist/templates/trellis/agents/subnode-profiles.json +14 -0
  26. package/dist/templates/trellis/index.d.ts +1 -0
  27. package/dist/templates/trellis/index.d.ts.map +1 -1
  28. package/dist/templates/trellis/index.js +2 -0
  29. package/dist/templates/trellis/index.js.map +1 -1
  30. package/dist/templates/trellis/scripts/subnode_artifact.py +263 -0
  31. package/package.json +2 -2
@@ -9,6 +9,7 @@ dispatches workers, makes the acceptance judgment, or mutates task state.
9
9
  from __future__ import annotations
10
10
 
11
11
  import argparse
12
+ import hashlib
12
13
  import json
13
14
  import re
14
15
  import sys
@@ -26,6 +27,7 @@ SCHEMA_VERSION = 2
26
27
  MAX_DRAFT_BYTES = 64 * 1024
27
28
  MAX_REPORT_BYTES = 128 * 1024
28
29
  MAX_WORKLOG_BYTES = 128 * 1024
30
+ MAX_QUEUE_BYTES = 128 * 1024
29
31
  ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
30
32
  CHECKPOINT_RE = re.compile(r"^<!-- trellis-checkpoint: (?P<payload>\{.*\}) -->$", re.MULTILINE)
31
33
  SECRET_PATTERNS = (
@@ -154,6 +156,98 @@ def _relative_to_repo(path: Path, repo_root: Path) -> str:
154
156
  _fail(f"path is outside repository: {path}")
155
157
 
156
158
 
159
+ def _queue_paths(task_dir: Path, work_id: str) -> tuple[Path, Path]:
160
+ work_dir = task_dir / "subnodes" / work_id
161
+ _assert_no_subpath_symlinks(task_dir, work_dir)
162
+ if not work_dir.exists() or not work_dir.is_dir():
163
+ _fail(f"queue work directory does not exist: {work_dir}")
164
+ return work_dir / "queue.json", work_dir / "queue-abandoned.json"
165
+
166
+
167
+ def _queue_context(args: argparse.Namespace) -> tuple[Path, str, Path, Path, Path]:
168
+ repo_root = get_repo_root()
169
+ task_dir = resolve_task_dir(args.task, repo_root)
170
+ if task_dir is None or not is_within_tasks_dir(task_dir, repo_root):
171
+ _fail("--task must identify a direct active task directory")
172
+ if task_dir.is_symlink():
173
+ _fail(f"refusing symlinked task directory: {task_dir}")
174
+ work_id = _require_id(args.work_id, "--work-id")
175
+ task_id = _task_id(task_dir)
176
+ queue_path, abandoned_path = _queue_paths(task_dir, work_id)
177
+ for path in (queue_path, abandoned_path):
178
+ if path.is_symlink():
179
+ _fail(f"refusing symlinked queue artifact: {path}")
180
+ return repo_root, task_id, task_dir, queue_path, abandoned_path
181
+
182
+
183
+ def _write_json_exclusive(path: Path, value: dict[str, Any], label: str) -> None:
184
+ if path.is_symlink():
185
+ _fail(f"refusing symlinked {label}: {path}")
186
+ try:
187
+ with path.open("x", encoding="utf-8") as stream:
188
+ json.dump(value, stream, indent=2, ensure_ascii=False)
189
+ stream.write("\n")
190
+ except FileExistsError:
191
+ _fail(f"{label} already exists and cannot be replaced: {path}")
192
+ except OSError as exc:
193
+ _fail(f"could not write {label} {path}: {exc}")
194
+
195
+
196
+ def _validate_queue_file(
197
+ queue_path: Path,
198
+ task_dir: Path,
199
+ task_id: str,
200
+ work_id: str,
201
+ repo_root: Path,
202
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
203
+ queue, _raw = _read_json_object(queue_path, MAX_QUEUE_BYTES, "queue")
204
+ if queue.get("schema_version") != 1:
205
+ _fail("queue.schema_version must be 1")
206
+ if queue.get("task_id") != task_id:
207
+ _fail("queue.task_id does not match the task")
208
+ if queue.get("work_id") != work_id:
209
+ _fail("queue.work_id does not match the requested work")
210
+ channel = queue.get("channel_ref")
211
+ if not isinstance(channel, dict):
212
+ _fail("queue.channel_ref must be an object")
213
+ channel_name = _require_text(channel.get("name"), "queue.channel_ref.name", max_len=128)
214
+ channel_scope = _require_text(channel.get("scope"), "queue.channel_ref.scope", max_len=32)
215
+ if channel_scope not in {"project", "global"}:
216
+ _fail("queue.channel_ref.scope must be project or global")
217
+ items = queue.get("items")
218
+ if not isinstance(items, list) or not items:
219
+ _fail("queue.items must be a non-empty list")
220
+ seen: set[str] = set()
221
+ validated: list[dict[str, Any]] = []
222
+ for index, item in enumerate(items):
223
+ if not isinstance(item, dict):
224
+ _fail(f"queue.items[{index}] must be an object")
225
+ subnode_id = _require_id(item.get("subnode_id"), f"queue.items[{index}].subnode_id")
226
+ if subnode_id in seen:
227
+ _fail("queue.items contains duplicate subnode_id values")
228
+ seen.add(subnode_id)
229
+ brief_path = _require_text(item.get("brief_path"), f"queue.items[{index}].brief_path")
230
+ brief, raw, brief_file, node_dir, brief_task_dir = _validate_brief_file(
231
+ brief_path, repo_root
232
+ )
233
+ if brief_task_dir != task_dir:
234
+ _fail(f"queue item {subnode_id} brief belongs to another task")
235
+ if brief.get("work_id") != work_id or brief.get("subnode_id") != subnode_id:
236
+ _fail(f"queue item {subnode_id} brief identity does not match")
237
+ expected_brief_path = _relative_to_repo(node_dir / "brief.json", repo_root)
238
+ if _relative_to_repo(brief_file, repo_root) != expected_brief_path:
239
+ _fail(f"queue item {subnode_id} brief_path is not canonical")
240
+ if brief.get("channel_ref", {}).get("name") != channel_name:
241
+ _fail(f"queue item {subnode_id} channel name does not match the queue")
242
+ if brief.get("channel_ref", {}).get("scope") != channel_scope:
243
+ _fail(f"queue item {subnode_id} channel scope does not match the queue")
244
+ digest = hashlib.sha256(raw).hexdigest()
245
+ if item.get("brief_digest") != digest:
246
+ _fail(f"queue item {subnode_id} brief digest does not match")
247
+ validated.append({"subnode_id": subnode_id, "brief_path": brief_path, "brief_digest": digest})
248
+ return queue, validated
249
+
250
+
157
251
  def _validate_snapshot(value: Any) -> None:
158
252
  if not isinstance(value, list) or not value:
159
253
  _fail("source_snapshot must be a non-empty list")
@@ -595,6 +689,145 @@ def _init(args: argparse.Namespace) -> None:
595
689
  }))
596
690
 
597
691
 
692
+ def _queue_init(args: argparse.Namespace) -> None:
693
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
694
+ if queue_path.exists() or abandoned_path.exists():
695
+ _fail("queue already exists or has been abandoned")
696
+ if not args.brief:
697
+ _fail("queue init requires at least one --brief")
698
+ work_id = _require_id(args.work_id, "--work-id")
699
+ channel_name = _require_text(args.channel_name, "--channel-name", max_len=128)
700
+ channel_scope = _require_text(args.channel_scope, "--channel-scope", max_len=32)
701
+ if channel_scope not in {"project", "global"}:
702
+ _fail("--channel-scope must be project or global")
703
+ items: list[dict[str, str]] = []
704
+ seen: set[str] = set()
705
+ for brief_arg in args.brief:
706
+ brief, raw, brief_path, node_dir, brief_task_dir = _validate_brief_file(
707
+ brief_arg, repo_root
708
+ )
709
+ subnode_id = _require_id(brief.get("subnode_id"), "brief.subnode_id")
710
+ if brief_task_dir != task_dir:
711
+ _fail(f"queue item {subnode_id} brief belongs to another task")
712
+ if brief.get("work_id") != work_id:
713
+ _fail(f"queue item {subnode_id} brief belongs to another work")
714
+ if subnode_id in seen:
715
+ _fail("queue init contains duplicate subnode_id values")
716
+ if brief.get("channel_ref", {}).get("name") != channel_name:
717
+ _fail(f"queue item {subnode_id} channel name does not match")
718
+ if brief.get("channel_ref", {}).get("scope") != channel_scope:
719
+ _fail(f"queue item {subnode_id} channel scope does not match")
720
+ expected_path = _relative_to_repo(node_dir / "brief.json", repo_root)
721
+ if _relative_to_repo(brief_path, repo_root) != expected_path:
722
+ _fail(f"queue item {subnode_id} brief path is not canonical")
723
+ seen.add(subnode_id)
724
+ items.append({
725
+ "subnode_id": subnode_id,
726
+ "brief_path": expected_path,
727
+ "brief_digest": hashlib.sha256(raw).hexdigest(),
728
+ })
729
+ queue = {
730
+ "schema_version": 1,
731
+ "task_id": task_id,
732
+ "work_id": work_id,
733
+ "channel_ref": {"name": channel_name, "scope": channel_scope},
734
+ "created_at": datetime.now(timezone.utc).isoformat(),
735
+ "items": items,
736
+ }
737
+ _write_json_exclusive(queue_path, queue, "queue")
738
+ print(json.dumps({"queue": _relative_to_repo(queue_path, repo_root), "items": items}))
739
+
740
+
741
+ def _queue_validate(args: argparse.Namespace) -> None:
742
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
743
+ _queue, items = _validate_queue_file(
744
+ queue_path, task_dir, task_id, args.work_id, repo_root
745
+ )
746
+ print(json.dumps({
747
+ "status": "abandoned" if abandoned_path.exists() else "valid",
748
+ "queue": _relative_to_repo(queue_path, repo_root),
749
+ "task_id": task_id,
750
+ "work_id": args.work_id,
751
+ "items": items,
752
+ "item_count": len(items),
753
+ }))
754
+
755
+
756
+ def _queue_claim(args: argparse.Namespace) -> None:
757
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
758
+ if abandoned_path.exists():
759
+ _fail("queue has been abandoned; no further dispatch claim is allowed")
760
+ _queue, items = _validate_queue_file(
761
+ queue_path, task_dir, task_id, args.work_id, repo_root
762
+ )
763
+ subnode_id = _require_id(args.subnode_id, "--subnode-id")
764
+ item = next((candidate for candidate in items if candidate["subnode_id"] == subnode_id), None)
765
+ if item is None:
766
+ _fail(f"subnode is not listed in the queue: {subnode_id}")
767
+ claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
768
+ _assert_no_subpath_symlinks(task_dir, claim_path)
769
+ claim = {
770
+ "schema_version": 1,
771
+ "task_id": task_id,
772
+ "work_id": args.work_id,
773
+ "subnode_id": subnode_id,
774
+ "brief_digest": item["brief_digest"],
775
+ "claimed_at": datetime.now(timezone.utc).isoformat(),
776
+ }
777
+ _write_json_exclusive(claim_path, claim, "dispatch claim")
778
+ print(json.dumps({
779
+ "claim": _relative_to_repo(claim_path, repo_root),
780
+ "subnode_id": subnode_id,
781
+ "brief_digest": item["brief_digest"],
782
+ }))
783
+
784
+
785
+ def _queue_abandon(args: argparse.Namespace) -> None:
786
+ repo_root, task_id, task_dir, queue_path, abandoned_path = _queue_context(args)
787
+ _queue, items = _validate_queue_file(
788
+ queue_path, task_dir, task_id, args.work_id, repo_root
789
+ )
790
+ item_ids = {item["subnode_id"] for item in items}
791
+ claimed_ids: set[str] = set()
792
+ for item in items:
793
+ subnode_id = item["subnode_id"]
794
+ claim_path = task_dir / "subnodes" / args.work_id / subnode_id / "dispatch-claim.json"
795
+ _assert_no_subpath_symlinks(task_dir, claim_path)
796
+ if not claim_path.exists():
797
+ continue
798
+ claim, _raw = _read_json_object(claim_path, MAX_DRAFT_BYTES, "dispatch claim")
799
+ if (
800
+ claim.get("schema_version") != 1
801
+ or claim.get("task_id") != task_id
802
+ or claim.get("work_id") != args.work_id
803
+ or claim.get("subnode_id") != subnode_id
804
+ or claim.get("brief_digest") != item["brief_digest"]
805
+ ):
806
+ _fail(f"dispatch claim does not match queue item {subnode_id}")
807
+ claimed_ids.add(subnode_id)
808
+ dispatched = [_require_id(value, "--dispatched") for value in (args.dispatched or [])]
809
+ pending = [_require_id(value, "--pending") for value in (args.pending or [])]
810
+ if len(set(dispatched)) != len(dispatched) or len(set(pending)) != len(pending):
811
+ _fail("queue abandonment lists must not contain duplicates")
812
+ if not set(dispatched).issubset(item_ids) or not set(pending).issubset(item_ids):
813
+ _fail("queue abandonment lists must contain only queued subnode IDs")
814
+ if set(dispatched) & set(pending):
815
+ _fail("a subnode cannot be both dispatched and pending")
816
+ if set(dispatched) != claimed_ids or set(pending) != item_ids - claimed_ids:
817
+ _fail("queue abandonment lists must exactly match dispatch claims and remaining queued items")
818
+ abandoned = {
819
+ "schema_version": 1,
820
+ "task_id": task_id,
821
+ "work_id": args.work_id,
822
+ "reason": _require_text(args.reason, "--reason"),
823
+ "dispatched": dispatched,
824
+ "pending": pending,
825
+ "abandoned_at": datetime.now(timezone.utc).isoformat(),
826
+ }
827
+ _write_json_exclusive(abandoned_path, abandoned, "queue abandonment")
828
+ print(json.dumps({"status": "abandoned", "queue": _relative_to_repo(abandoned_path, repo_root)}))
829
+
830
+
598
831
  def _validate(args: argparse.Namespace) -> None:
599
832
  repo_root = get_repo_root()
600
833
  report, _evidence, _brief, _node_dir_value, concerns = _validate_report_file(args.report, repo_root)
@@ -718,6 +951,36 @@ def _parser() -> argparse.ArgumentParser:
718
951
  counter.add_argument("--primary", required=True, help="primary subnode artifact directory")
719
952
  counter.add_argument("--counter", required=True, help="counter subnode artifact directory")
720
953
  counter.set_defaults(handler=_validate_counter)
954
+
955
+ queue = subparsers.add_parser("queue", help="manage one write-once subnode queue")
956
+ queue_parsers = queue.add_subparsers(dest="queue_command", required=True)
957
+
958
+ queue_init = queue_parsers.add_parser("init", help="write one FIFO queue manifest")
959
+ queue_init.add_argument("--task", required=True)
960
+ queue_init.add_argument("--work-id", required=True)
961
+ queue_init.add_argument("--channel-name", required=True)
962
+ queue_init.add_argument("--channel-scope", required=True)
963
+ queue_init.add_argument("--brief", action="append", required=True)
964
+ queue_init.set_defaults(handler=_queue_init)
965
+
966
+ queue_validate = queue_parsers.add_parser("validate", help="validate a queue manifest")
967
+ queue_validate.add_argument("--task", required=True)
968
+ queue_validate.add_argument("--work-id", required=True)
969
+ queue_validate.set_defaults(handler=_queue_validate)
970
+
971
+ queue_claim = queue_parsers.add_parser("claim", help="write one dispatch intent")
972
+ queue_claim.add_argument("--task", required=True)
973
+ queue_claim.add_argument("--work-id", required=True)
974
+ queue_claim.add_argument("--subnode-id", required=True)
975
+ queue_claim.set_defaults(handler=_queue_claim)
976
+
977
+ queue_abandon = queue_parsers.add_parser("abandon", help="stop a queue permanently")
978
+ queue_abandon.add_argument("--task", required=True)
979
+ queue_abandon.add_argument("--work-id", required=True)
980
+ queue_abandon.add_argument("--reason", required=True)
981
+ queue_abandon.add_argument("--dispatched", action="append")
982
+ queue_abandon.add_argument("--pending", action="append")
983
+ queue_abandon.set_defaults(handler=_queue_abandon)
721
984
  return parser
722
985
 
723
986
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pennixrv/trellis",
3
- "version": "0.7.0-beta.10",
3
+ "version": "0.7.0-beta.11",
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.7.0-beta.10"
37
+ "@pennixrv/trellis-core": "0.7.0-beta.11"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@eslint/js": "^9.18.0",