@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.
- package/dist/commands/channel/agent-loader.d.ts +3 -1
- package/dist/commands/channel/agent-loader.d.ts.map +1 -1
- package/dist/commands/channel/agent-loader.js +32 -1
- package/dist/commands/channel/agent-loader.js.map +1 -1
- package/dist/commands/channel/guard.d.ts +21 -2
- package/dist/commands/channel/guard.d.ts.map +1 -1
- package/dist/commands/channel/guard.js +105 -4
- package/dist/commands/channel/guard.js.map +1 -1
- package/dist/commands/channel/index.d.ts.map +1 -1
- package/dist/commands/channel/index.js +22 -5
- package/dist/commands/channel/index.js.map +1 -1
- package/dist/commands/channel/run.d.ts.map +1 -1
- package/dist/commands/channel/run.js +15 -9
- package/dist/commands/channel/run.js.map +1 -1
- package/dist/commands/channel/spawn.d.ts +4 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -1
- package/dist/commands/channel/spawn.js +59 -7
- package/dist/commands/channel/spawn.js.map +1 -1
- package/dist/commands/channel/workers.d.ts +8 -0
- package/dist/commands/channel/workers.d.ts.map +1 -0
- package/dist/commands/channel/workers.js +30 -0
- package/dist/commands/channel/workers.js.map +1 -0
- package/dist/migrations/manifests/0.6.27.json +9 -0
- package/dist/migrations/manifests/0.6.28.json +9 -0
- package/dist/migrations/manifests/0.6.29.json +9 -0
- package/dist/templates/common/bundled-skills/trellis-channel/references/command-reference.md +23 -4
- package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +37 -8
- package/dist/templates/common/bundled-skills/trellis-channel/references/workers.md +25 -6
- package/dist/templates/common/bundled-skills/trellis-meta/SKILL.md +2 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/multi-agent-channel.md +2 -1
- package/dist/templates/trellis/agents/subnode.env +4 -0
- package/dist/templates/trellis/agents/subnode.md +5 -0
- package/dist/templates/trellis/config.yaml +9 -1
- package/dist/templates/trellis/index.d.ts +5 -3
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +7 -3
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/ownership_record.py +414 -0
- package/dist/templates/trellis/scripts/subnode_artifact.py +100 -1
- package/dist/templates/trellis/scripts/task.py +114 -1
- 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,
|
|
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 =
|
|
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.
|
|
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.
|
|
37
|
+
"@pennixrv/trellis-core": "0.6.29"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@eslint/js": "^9.18.0",
|