@pennixrv/trellis 0.6.27 → 0.6.29

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 (41) hide show
  1. package/dist/commands/channel/agent-loader.d.ts +3 -1
  2. package/dist/commands/channel/agent-loader.d.ts.map +1 -1
  3. package/dist/commands/channel/agent-loader.js +32 -1
  4. package/dist/commands/channel/agent-loader.js.map +1 -1
  5. package/dist/commands/channel/guard.d.ts +21 -2
  6. package/dist/commands/channel/guard.d.ts.map +1 -1
  7. package/dist/commands/channel/guard.js +105 -4
  8. package/dist/commands/channel/guard.js.map +1 -1
  9. package/dist/commands/channel/index.d.ts.map +1 -1
  10. package/dist/commands/channel/index.js +22 -5
  11. package/dist/commands/channel/index.js.map +1 -1
  12. package/dist/commands/channel/run.d.ts.map +1 -1
  13. package/dist/commands/channel/run.js +15 -9
  14. package/dist/commands/channel/run.js.map +1 -1
  15. package/dist/commands/channel/spawn.d.ts +4 -0
  16. package/dist/commands/channel/spawn.d.ts.map +1 -1
  17. package/dist/commands/channel/spawn.js +59 -7
  18. package/dist/commands/channel/spawn.js.map +1 -1
  19. package/dist/commands/channel/workers.d.ts +8 -0
  20. package/dist/commands/channel/workers.d.ts.map +1 -0
  21. package/dist/commands/channel/workers.js +30 -0
  22. package/dist/commands/channel/workers.js.map +1 -0
  23. package/dist/migrations/manifests/0.6.27.json +9 -0
  24. package/dist/migrations/manifests/0.6.28.json +9 -0
  25. package/dist/migrations/manifests/0.6.29.json +9 -0
  26. package/dist/templates/common/bundled-skills/trellis-channel/references/command-reference.md +23 -4
  27. package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +37 -8
  28. package/dist/templates/common/bundled-skills/trellis-channel/references/workers.md +25 -6
  29. package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
  30. package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-channel.md +2 -1
  31. package/dist/templates/trellis/agents/subnode.env +4 -0
  32. package/dist/templates/trellis/agents/subnode.md +5 -0
  33. package/dist/templates/trellis/config.yaml +9 -1
  34. package/dist/templates/trellis/index.d.ts +5 -3
  35. package/dist/templates/trellis/index.d.ts.map +1 -1
  36. package/dist/templates/trellis/index.js +7 -3
  37. package/dist/templates/trellis/index.js.map +1 -1
  38. package/dist/templates/trellis/scripts/common/ownership_record.py +414 -0
  39. package/dist/templates/trellis/scripts/subnode_artifact.py +100 -1
  40. package/dist/templates/trellis/scripts/task.py +114 -1
  41. package/package.json +2 -2
@@ -3,7 +3,7 @@
3
3
 
4
4
  The coordinator creates an immutable brief before dispatching a subnode. The
5
5
  subnode appends its worklog and writes its own final report. This helper never
6
- dispatches workers, decides acceptance, or mutates task state.
6
+ dispatches workers, makes the acceptance judgment, or mutates task state.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -12,6 +12,7 @@ import argparse
12
12
  import json
13
13
  import re
14
14
  import sys
15
+ from datetime import datetime, timezone
15
16
  from pathlib import Path
16
17
  from typing import Any, NoReturn
17
18
 
@@ -30,6 +31,13 @@ SECRET_PATTERNS = (
30
31
  re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"),
31
32
  re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
32
33
  )
34
+ TERMINAL_LIFECYCLES = {"done", "error", "killed", "crashed"}
35
+ DISPOSITION_OUTCOMES = {"accepted", "rejected", "deferred"}
36
+ REQUIRED_DISPOSITION_CHECKS = {
37
+ "report_validation",
38
+ "source_recheck",
39
+ "protected_target_check",
40
+ }
33
41
 
34
42
 
35
43
  class ArtifactError(Exception):
@@ -232,6 +240,8 @@ def _validate_brief_data(
232
240
  _require_text_list(brief.get("stop_conditions"), "brief.stop_conditions")
233
241
  _require_text(brief.get("deadline"), "brief.deadline", max_len=128)
234
242
  _validate_channel_ref(brief.get("channel_ref"))
243
+ if brief["channel_ref"]["worker_handle"] != subnode_id:
244
+ _fail("channel_ref.worker_handle must match brief.subnode_id")
235
245
  _validate_retry_target(
236
246
  brief.get("retry_of"),
237
247
  task_dir,
@@ -362,6 +372,41 @@ def _validate_report_file(
362
372
  return report, evidence, brief, node_dir
363
373
 
364
374
 
375
+ def _validate_disposition_data(
376
+ disposition: dict[str, Any],
377
+ report: dict[str, Any],
378
+ brief: dict[str, Any],
379
+ ) -> None:
380
+ if disposition.get("schema_version") != SCHEMA_VERSION:
381
+ _fail(f"disposition.schema_version must be {SCHEMA_VERSION}")
382
+ for field in ("task_id", "work_id", "subnode_id", "role_id"):
383
+ if disposition.get(field) != brief.get(field):
384
+ _fail(f"disposition.{field} does not match brief.{field}")
385
+ if disposition.get("report_path") != brief.get("report_path"):
386
+ _fail("disposition.report_path does not match brief.report_path")
387
+ if disposition.get("report_status") != report.get("status"):
388
+ _fail("disposition.report_status does not match report.status")
389
+ if disposition.get("outcome") not in DISPOSITION_OUTCOMES:
390
+ _fail("disposition.outcome must be accepted, rejected, or deferred")
391
+ terminal = disposition.get("terminal")
392
+ if not isinstance(terminal, dict):
393
+ _fail("disposition.terminal must be an object")
394
+ if terminal.get("lifecycle") not in TERMINAL_LIFECYCLES:
395
+ _fail("disposition.terminal.lifecycle must be done, error, killed, or crashed")
396
+ sequence = terminal.get("seq")
397
+ if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence < 1:
398
+ _fail("disposition.terminal.seq must be a positive integer")
399
+ _require_text(terminal.get("observed_at"), "disposition.terminal.observed_at", max_len=128)
400
+ checks = _require_text_list(disposition.get("checks"), "disposition.checks")
401
+ if len(checks) != len(set(checks)):
402
+ _fail("disposition.checks must not contain duplicates")
403
+ missing = REQUIRED_DISPOSITION_CHECKS.difference(checks)
404
+ if missing:
405
+ _fail("disposition.checks is missing: " + ", ".join(sorted(missing)))
406
+ _require_text(disposition.get("reason"), "disposition.reason")
407
+ _require_text(disposition.get("decided_at"), "disposition.decided_at", max_len=128)
408
+
409
+
365
410
  def _init(args: argparse.Namespace) -> None:
366
411
  repo_root = get_repo_root()
367
412
  task_dir = resolve_task_dir(args.task, repo_root)
@@ -425,6 +470,42 @@ def _validate(args: argparse.Namespace) -> None:
425
470
  print(f"Validated pending-review report: {report['subnode_id']}")
426
471
 
427
472
 
473
+ def _disposition(args: argparse.Namespace) -> None:
474
+ repo_root = get_repo_root()
475
+ report, _evidence, brief, node_dir = _validate_report_file(args.report, repo_root)
476
+ disposition_path = node_dir / "disposition.json"
477
+ if disposition_path.exists() or disposition_path.is_symlink():
478
+ _fail(f"disposition already exists and cannot be replaced: {disposition_path}")
479
+ disposition = {
480
+ "schema_version": SCHEMA_VERSION,
481
+ "task_id": brief["task_id"],
482
+ "work_id": brief["work_id"],
483
+ "subnode_id": brief["subnode_id"],
484
+ "role_id": brief["role_id"],
485
+ "report_path": brief["report_path"],
486
+ "report_status": report["status"],
487
+ "outcome": args.outcome,
488
+ "terminal": {
489
+ "lifecycle": args.terminal_lifecycle,
490
+ "seq": args.terminal_seq,
491
+ "observed_at": args.terminal_at,
492
+ },
493
+ "checks": args.check,
494
+ "reason": args.reason,
495
+ "decided_at": datetime.now(timezone.utc).isoformat(),
496
+ }
497
+ _validate_disposition_data(disposition, report, brief)
498
+ try:
499
+ with disposition_path.open("x", encoding="utf-8") as stream:
500
+ json.dump(disposition, stream, indent=2, ensure_ascii=False)
501
+ stream.write("\n")
502
+ except FileExistsError:
503
+ _fail(f"disposition already exists and cannot be replaced: {disposition_path}")
504
+ except OSError as exc:
505
+ _fail(f"could not write disposition {disposition_path}: {exc}")
506
+ print(f"Recorded coordinator disposition: {brief['subnode_id']} -> {args.outcome}")
507
+
508
+
428
509
  def _validate_counter(args: argparse.Namespace) -> None:
429
510
  repo_root = get_repo_root()
430
511
  primary_dir = Path(args.primary)
@@ -474,6 +555,24 @@ def _parser() -> argparse.ArgumentParser:
474
555
  validate.add_argument("--report", required=True)
475
556
  validate.set_defaults(handler=_validate)
476
557
 
558
+ disposition = subparsers.add_parser(
559
+ "disposition",
560
+ help="record one coordinator disposition for a validated report",
561
+ )
562
+ disposition.add_argument("--report", required=True)
563
+ disposition.add_argument("--outcome", choices=sorted(DISPOSITION_OUTCOMES), required=True)
564
+ disposition.add_argument("--terminal-lifecycle", choices=sorted(TERMINAL_LIFECYCLES), required=True)
565
+ disposition.add_argument("--terminal-seq", type=int, required=True)
566
+ disposition.add_argument("--terminal-at", required=True)
567
+ disposition.add_argument(
568
+ "--check",
569
+ action="append",
570
+ required=True,
571
+ help="independent check name (repeat; requires report_validation, source_recheck, and protected_target_check)",
572
+ )
573
+ disposition.add_argument("--reason", required=True)
574
+ disposition.set_defaults(handler=_disposition)
575
+
477
576
  counter = subparsers.add_parser(
478
577
  "validate-counter",
479
578
  help="validate two completed, independent subnode directories",
@@ -10,6 +10,7 @@ Usage:
10
10
  python3 task.py list-context <dir> # List jsonl entries
11
11
  python3 task.py start <dir> # Set active task, record current branch
12
12
  python3 task.py current [--source] [--json] # Show active task
13
+ python3 task.py ownership <operation> ... # Manage formal handoff task ownership
13
14
  python3 task.py finish # Clear active task
14
15
  python3 task.py set-branch <dir> <branch> # Set git branch
15
16
  python3 task.py set-base-branch <dir> <branch> # Set PR target branch
@@ -81,6 +82,17 @@ from common.continuation_record import (
81
82
  seal as seal_continuity,
82
83
  status as continuity_status,
83
84
  )
85
+ from common.ownership_record import (
86
+ OwnershipError,
87
+ archive as archive_ownership,
88
+ assert_task_mutation_allowed,
89
+ claim as claim_ownership,
90
+ consume as consume_ownership,
91
+ quiesce as quiesce_ownership,
92
+ retire as retire_ownership,
93
+ seal as seal_ownership,
94
+ status as ownership_status,
95
+ )
84
96
 
85
97
 
86
98
  # =============================================================================
@@ -198,6 +210,12 @@ def cmd_start(args: argparse.Namespace) -> int:
198
210
  print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.trellis/tasks/01-31-my-task')")
199
211
  return 1
200
212
 
213
+ try:
214
+ assert_task_mutation_allowed(repo_root, full_path)
215
+ except (OwnershipError, OSError) as exc:
216
+ print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
217
+ return 2
218
+
201
219
  # Context-manifest gate (#573): a seeded-but-uncurated implement/check
202
220
  # manifest means every sub-agent dispatched for this task runs with zero
203
221
  # spec context, and nothing downstream surfaces that to the main session.
@@ -282,15 +300,22 @@ def cmd_start(args: argparse.Namespace) -> int:
282
300
  def cmd_finish(args: argparse.Namespace) -> int:
283
301
  """Clear active task."""
284
302
  repo_root = get_repo_root()
285
- active = clear_active_task(repo_root)
303
+ active = resolve_active_task(repo_root)
286
304
  current = active.task_path
287
305
 
288
306
  if not current:
289
307
  print(colored("No current task set", Colors.YELLOW))
290
308
  return 0
291
309
 
310
+ try:
311
+ assert_task_mutation_allowed(repo_root, repo_root / current)
312
+ except (OwnershipError, OSError) as exc:
313
+ print(colored(f"Error: {exc}", Colors.RED), file=sys.stderr)
314
+ return 2
315
+
292
316
  # Resolve task.json path before clearing
293
317
  task_json_path = repo_root / current / FILE_TASK_JSON
318
+ clear_active_task(repo_root)
294
319
 
295
320
  print(colored(f"✓ Cleared current task (was: {current})", Colors.GREEN))
296
321
  print(f"Source: {active.source}")
@@ -382,6 +407,54 @@ def cmd_continuity(args: argparse.Namespace) -> int:
382
407
  return 2
383
408
 
384
409
 
410
+ def cmd_ownership(args: argparse.Namespace) -> int:
411
+ """Manage the task-bound formal handoff ownership record."""
412
+ repo_root = get_repo_root()
413
+ if args.ownership_command == "status":
414
+ try:
415
+ result = ownership_status(repo_root, args.task_id, args.handoff_id, args.core_digest)
416
+ except (OwnershipError, OSError) as exc:
417
+ print(json.dumps({"status": "withheld", "reason": str(exc)}, ensure_ascii=False, sort_keys=True))
418
+ return 2
419
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
420
+ return 0
421
+ if not getattr(args, "explicit_user_request", False):
422
+ print(colored("Error: ownership writes require --explicit-user-request", Colors.RED), file=sys.stderr)
423
+ return 2
424
+ try:
425
+ command = args.ownership_command
426
+ if command == "quiesce":
427
+ result = quiesce_ownership(
428
+ repo_root, args.task, args.handoff_id, args.core_digest, args.source_session_id
429
+ )
430
+ elif command == "seal":
431
+ result = seal_ownership(
432
+ repo_root, args.task_id, args.handoff_id, args.core_digest, args.expected_generation
433
+ )
434
+ elif command == "retire":
435
+ result = retire_ownership(
436
+ repo_root, args.task_id, args.handoff_id, args.core_digest,
437
+ args.expected_generation, args.archive_observation,
438
+ )
439
+ elif command == "claim":
440
+ result = claim_ownership(
441
+ repo_root, args.task_id, args.task, args.handoff_id, args.core_digest, args.expected_generation
442
+ )
443
+ elif command == "consume":
444
+ result = consume_ownership(
445
+ repo_root, args.task_id, args.handoff_id, args.core_digest, args.expected_generation
446
+ )
447
+ else:
448
+ result = archive_ownership(
449
+ repo_root, args.task_id, args.handoff_id, args.core_digest, args.expected_generation
450
+ )
451
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True))
452
+ return 0
453
+ except (OwnershipError, OSError) as exc:
454
+ print(json.dumps({"status": "withheld", "reason": str(exc)}, ensure_ascii=False, sort_keys=True))
455
+ return 2
456
+
457
+
385
458
  # =============================================================================
386
459
  # Command: list
387
460
  # =============================================================================
@@ -743,6 +816,45 @@ def main() -> int:
743
816
  continuity_clear.add_argument("--expected", required=True, help="The current record digest or absent")
744
817
  continuity_clear.add_argument("--explicit-user-request", action="store_true", help="Required write confirmation")
745
818
 
819
+ # ownership
820
+ p_ownership = subparsers.add_parser("ownership", help="Manage formal handoff task ownership")
821
+ ownership_sub = p_ownership.add_subparsers(dest="ownership_command", required=True)
822
+
823
+ def add_ownership_common(parser, *, expected=False):
824
+ parser.add_argument("--task-id", required=True, help="Task id bound to the handoff")
825
+ parser.add_argument("--handoff-id", required=True, help="Immutable handoff id")
826
+ parser.add_argument("--core-digest", required=True, help="Immutable handoff core digest")
827
+ if expected:
828
+ parser.add_argument("--expected-generation", required=True, type=int)
829
+ parser.add_argument("--explicit-user-request", action="store_true", help="Required write confirmation")
830
+
831
+ ownership_quiesce = ownership_sub.add_parser("quiesce", help="Begin source handoff quiescence")
832
+ ownership_quiesce.add_argument("--task", required=True, help="Current task path")
833
+ ownership_quiesce.add_argument("--source-session-id", required=True)
834
+ add_ownership_common(ownership_quiesce)
835
+
836
+ ownership_seal = ownership_sub.add_parser("seal", help="Seal the source ownership boundary")
837
+ add_ownership_common(ownership_seal, expected=True)
838
+
839
+ ownership_retire = ownership_sub.add_parser("retire", help="Retire source and expose handoff")
840
+ ownership_retire.add_argument("--archive-observation", required=True, choices=("not_required", "observed"))
841
+ add_ownership_common(ownership_retire, expected=True)
842
+
843
+ ownership_claim = ownership_sub.add_parser("claim", help="Claim a ready handoff as this session")
844
+ ownership_claim.add_argument("--task", required=True, help="Task path to bind to this session")
845
+ add_ownership_common(ownership_claim, expected=True)
846
+
847
+ ownership_consume = ownership_sub.add_parser("consume", help="Record target consumption")
848
+ add_ownership_common(ownership_consume, expected=True)
849
+
850
+ ownership_archive = ownership_sub.add_parser("archive", help="Record post-consume retention archive")
851
+ add_ownership_common(ownership_archive, expected=True)
852
+
853
+ ownership_status_parser = ownership_sub.add_parser("status", help="Show ownership status")
854
+ add_ownership_common(ownership_status_parser)
855
+ ownership_status_parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
856
+ ownership_status_parser.set_defaults(explicit_user_request=True)
857
+
746
858
  # finish
747
859
  subparsers.add_parser("finish", help="Clear active task")
748
860
 
@@ -824,6 +936,7 @@ def main() -> int:
824
936
  "start": cmd_start,
825
937
  "current": cmd_current,
826
938
  "continuity": cmd_continuity,
939
+ "ownership": cmd_ownership,
827
940
  "finish": cmd_finish,
828
941
  "set-branch": cmd_set_branch,
829
942
  "set-base-branch": cmd_set_base_branch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pennixrv/trellis",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
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.27"
37
+ "@pennixrv/trellis-core": "0.6.29"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@eslint/js": "^9.18.0",