troven 0.1.0__py3-none-any.whl
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.
- troven/__init__.py +3 -0
- troven/__main__.py +7 -0
- troven/cli.py +144 -0
- troven/commands/__init__.py +1 -0
- troven/commands/accept.py +134 -0
- troven/commands/create.py +193 -0
- troven/commands/install_skill.py +51 -0
- troven/commands/list_cmd.py +79 -0
- troven/commands/open_cmd.py +137 -0
- troven/commands/reject.py +91 -0
- troven/commands/show.py +25 -0
- troven/errors.py +68 -0
- troven/git.py +271 -0
- troven/skill/SKILL.md +79 -0
- troven/state.py +257 -0
- troven-0.1.0.dist-info/METADATA +75 -0
- troven-0.1.0.dist-info/RECORD +20 -0
- troven-0.1.0.dist-info/WHEEL +4 -0
- troven-0.1.0.dist-info/entry_points.txt +2 -0
- troven-0.1.0.dist-info/licenses/LICENSE +21 -0
troven/__init__.py
ADDED
troven/__main__.py
ADDED
troven/cli.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""CLI argument parsing and command dispatch for Troven."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from troven import __version__
|
|
9
|
+
from troven.commands.accept import execute_accept
|
|
10
|
+
from troven.commands.create import execute_create
|
|
11
|
+
from troven.commands.list_cmd import execute_list
|
|
12
|
+
from troven.commands.open_cmd import execute_open
|
|
13
|
+
from troven.commands.reject import execute_reject
|
|
14
|
+
from troven.commands.show import execute_show
|
|
15
|
+
from troven.errors import TrovenError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="troven",
|
|
21
|
+
description="Git-native CLI for tracking change intents and speculative development.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
24
|
+
|
|
25
|
+
subparsers = parser.add_subparsers(dest="subcommand", title="subcommands", required=True)
|
|
26
|
+
|
|
27
|
+
# create
|
|
28
|
+
create_p = subparsers.add_parser(
|
|
29
|
+
"create",
|
|
30
|
+
help="Create a new troven worktree and initialize its change intent record.",
|
|
31
|
+
)
|
|
32
|
+
create_p.add_argument("name", help="Dash-separated name for the troven (e.g. 'fast-lexer-simd')")
|
|
33
|
+
create_p.add_argument("--from", dest="from_ref", help="Base branch, commit, or parent troven ID")
|
|
34
|
+
create_p.add_argument("--repo", dest="repo_url", help="Git repository URL to clone first")
|
|
35
|
+
create_p.add_argument("-f", "--force", action="store_true", help="Skip confirmation prompts")
|
|
36
|
+
create_p.add_argument("--no-exec", action="store_true", help="Initialize worktree without launching agent")
|
|
37
|
+
create_p.add_argument("command", nargs="*", help="Custom command to run in worktree (after --)")
|
|
38
|
+
|
|
39
|
+
# open
|
|
40
|
+
open_p = subparsers.add_parser(
|
|
41
|
+
"open",
|
|
42
|
+
help="Open an existing active or rejected troven in a worktree.",
|
|
43
|
+
)
|
|
44
|
+
open_p.add_argument("id", nargs="?", help="Troven ID (defaults to current branch/worktree)")
|
|
45
|
+
open_p.add_argument("--no-exec", action="store_true", help="Mount worktree without launching agent")
|
|
46
|
+
open_p.add_argument("command", nargs="*", help="Custom command to run in worktree (after --)")
|
|
47
|
+
|
|
48
|
+
# accept
|
|
49
|
+
accept_p = subparsers.add_parser(
|
|
50
|
+
"accept",
|
|
51
|
+
help="Incorporate an active troven's changes into the target branch.",
|
|
52
|
+
)
|
|
53
|
+
accept_p.add_argument("id", nargs="?", help="Troven ID (defaults to current worktree/branch)")
|
|
54
|
+
accept_p.add_argument("--into", dest="into_branch", help="Target branch (defaults to base_branch)")
|
|
55
|
+
accept_p.add_argument("-f", "--force", action="store_true", help="Bypass foreign repo confirmation")
|
|
56
|
+
|
|
57
|
+
# reject
|
|
58
|
+
reject_p = subparsers.add_parser(
|
|
59
|
+
"reject",
|
|
60
|
+
help="Close an active troven without merging; preserve rejection knowledge in archive ref.",
|
|
61
|
+
)
|
|
62
|
+
reject_p.add_argument("id", nargs="?", help="Troven ID (defaults to current worktree/branch)")
|
|
63
|
+
|
|
64
|
+
# list
|
|
65
|
+
list_p = subparsers.add_parser(
|
|
66
|
+
"list",
|
|
67
|
+
help="List all trovens in a hierarchical tree view.",
|
|
68
|
+
)
|
|
69
|
+
list_p.add_argument("--status", choices=["active", "accepted", "rejected"], help="Filter by status")
|
|
70
|
+
|
|
71
|
+
# show
|
|
72
|
+
show_p = subparsers.add_parser(
|
|
73
|
+
"show",
|
|
74
|
+
help="Print the full markdown record for a troven.",
|
|
75
|
+
)
|
|
76
|
+
show_p.add_argument("id", nargs="?", help="Troven ID (defaults to current worktree/branch)")
|
|
77
|
+
|
|
78
|
+
# install-skill
|
|
79
|
+
install_skill_p = subparsers.add_parser(
|
|
80
|
+
"install-skill",
|
|
81
|
+
help="Install the troven agent skill to ~/.agents/skills/troven and ~/.gemini/config/skills/troven.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(argv: list[str] | None = None) -> int:
|
|
88
|
+
parser = build_parser()
|
|
89
|
+
args = parser.parse_args(argv)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
if args.subcommand == "create":
|
|
93
|
+
cmd = args.command
|
|
94
|
+
if cmd and cmd[0] == "--":
|
|
95
|
+
cmd = cmd[1:]
|
|
96
|
+
execute_create(
|
|
97
|
+
name=args.name,
|
|
98
|
+
from_ref=args.from_ref,
|
|
99
|
+
repo_url=args.repo_url,
|
|
100
|
+
force=args.force,
|
|
101
|
+
command=cmd,
|
|
102
|
+
no_exec=args.no_exec,
|
|
103
|
+
)
|
|
104
|
+
elif args.subcommand == "open":
|
|
105
|
+
cmd = args.command
|
|
106
|
+
if cmd and cmd[0] == "--":
|
|
107
|
+
cmd = cmd[1:]
|
|
108
|
+
execute_open(
|
|
109
|
+
troven_id=args.id,
|
|
110
|
+
command=cmd,
|
|
111
|
+
no_exec=args.no_exec,
|
|
112
|
+
)
|
|
113
|
+
elif args.subcommand == "accept":
|
|
114
|
+
execute_accept(
|
|
115
|
+
troven_id=args.id,
|
|
116
|
+
into_branch=args.into_branch,
|
|
117
|
+
force=args.force,
|
|
118
|
+
)
|
|
119
|
+
elif args.subcommand == "reject":
|
|
120
|
+
execute_reject(
|
|
121
|
+
troven_id=args.id,
|
|
122
|
+
)
|
|
123
|
+
elif args.subcommand == "list":
|
|
124
|
+
execute_list(
|
|
125
|
+
status_filter=args.status,
|
|
126
|
+
)
|
|
127
|
+
elif args.subcommand == "show":
|
|
128
|
+
execute_show(
|
|
129
|
+
troven_id=args.id,
|
|
130
|
+
)
|
|
131
|
+
elif args.subcommand == "install-skill":
|
|
132
|
+
from troven.commands.install_skill import execute_install_skill
|
|
133
|
+
execute_install_skill()
|
|
134
|
+
return 0
|
|
135
|
+
except TrovenError as err:
|
|
136
|
+
print(f"\033[31mError:\033[0m {err}", file=sys.stderr)
|
|
137
|
+
return 1
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
print("\nAborted.", file=sys.stderr)
|
|
140
|
+
return 130
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Troven command implementations."""
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Implementation of 'troven accept' subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from troven import git
|
|
10
|
+
from troven.commands.open_cmd import detect_current_troven_id
|
|
11
|
+
from troven.errors import (
|
|
12
|
+
InvalidStateError,
|
|
13
|
+
MergeConflictError,
|
|
14
|
+
RebaseConflictError,
|
|
15
|
+
TrovenError,
|
|
16
|
+
TrovenNotFoundError,
|
|
17
|
+
)
|
|
18
|
+
from troven.state import (
|
|
19
|
+
get_troven_file,
|
|
20
|
+
load_troven,
|
|
21
|
+
save_troven,
|
|
22
|
+
validate_summary_non_empty,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def execute_accept(
|
|
27
|
+
troven_id: str | None = None,
|
|
28
|
+
into_branch: str | None = None,
|
|
29
|
+
force: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Incorporate active troven changes into the target branch."""
|
|
32
|
+
repo_root = git.get_main_repo_root()
|
|
33
|
+
|
|
34
|
+
if not troven_id:
|
|
35
|
+
detected = detect_current_troven_id(repo_root)
|
|
36
|
+
if not detected:
|
|
37
|
+
raise TrovenError(
|
|
38
|
+
"Troven ID required when not run from inside a troven worktree.",
|
|
39
|
+
remediation="Specify a troven ID: 'troven accept <id>' or run inside the worktree.",
|
|
40
|
+
)
|
|
41
|
+
troven_id = detected
|
|
42
|
+
|
|
43
|
+
clean_id = troven_id.removeprefix("troven/")
|
|
44
|
+
branch_name = f"troven/{clean_id}"
|
|
45
|
+
|
|
46
|
+
if not git.branch_exists(branch_name, cwd=repo_root):
|
|
47
|
+
raise TrovenNotFoundError(
|
|
48
|
+
f"Active branch '{branch_name}' not found.",
|
|
49
|
+
remediation=f"Check 'troven list' to view available trovens.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Locate worktree
|
|
53
|
+
worktree_dir = git.find_worktree_for_branch(branch_name, cwd=repo_root)
|
|
54
|
+
|
|
55
|
+
# Load and validate troven record from worktree if present, else base repo
|
|
56
|
+
if worktree_dir and worktree_dir.exists() and get_troven_file(worktree_dir, clean_id).exists():
|
|
57
|
+
md_source_dir = worktree_dir
|
|
58
|
+
else:
|
|
59
|
+
md_source_dir = repo_root
|
|
60
|
+
record = load_troven(md_source_dir, clean_id)
|
|
61
|
+
md_path = get_troven_file(md_source_dir, clean_id)
|
|
62
|
+
|
|
63
|
+
if record.status != "active":
|
|
64
|
+
raise InvalidStateError(
|
|
65
|
+
f"Troven '{clean_id}' is in state '{record.status}', not 'active'.",
|
|
66
|
+
remediation="Only active trovens can be accepted.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Gate-check summary
|
|
70
|
+
validate_summary_non_empty(record, md_path)
|
|
71
|
+
|
|
72
|
+
target_branch = into_branch or record.base_branch or "master"
|
|
73
|
+
|
|
74
|
+
# Foreign repo warning check
|
|
75
|
+
if not (repo_root / ".troven").exists() and not force and sys.stdin.isatty():
|
|
76
|
+
ans = input(
|
|
77
|
+
"This repository does not appear to use troven in its target branch.\n"
|
|
78
|
+
f"Accepting will add .troven/ to '{target_branch}'. Confirm? [y/N]: "
|
|
79
|
+
).strip()
|
|
80
|
+
if ans.lower() not in ("y", "yes"):
|
|
81
|
+
print("Aborted.")
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
|
|
84
|
+
# Ensure any pending .troven record changes inside worktree are committed to the branch
|
|
85
|
+
if worktree_dir and worktree_dir.exists():
|
|
86
|
+
wt_md = get_troven_file(worktree_dir, clean_id)
|
|
87
|
+
if wt_md.exists():
|
|
88
|
+
git.commit_files([wt_md], f"troven: record summary for {clean_id}", cwd=worktree_dir)
|
|
89
|
+
|
|
90
|
+
# Switch base repo to target branch
|
|
91
|
+
git.checkout_branch(target_branch, cwd=repo_root)
|
|
92
|
+
|
|
93
|
+
# Attempt fast-forward merge
|
|
94
|
+
print(f"Fast-forward merging '{branch_name}' into '{target_branch}'...")
|
|
95
|
+
if not git.merge_ff_only(branch_name, cwd=repo_root):
|
|
96
|
+
print(f"Branches have diverged. Attempting rebase inside worktree...")
|
|
97
|
+
if not worktree_dir or not worktree_dir.exists():
|
|
98
|
+
raise MergeConflictError(
|
|
99
|
+
f"Fast-forward merge failed and worktree for '{branch_name}' is not found locally.",
|
|
100
|
+
remediation=f"Open the troven with 'troven open {clean_id}', rebase onto '{target_branch}', and try again.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not git.rebase(target_branch, cwd=worktree_dir):
|
|
104
|
+
git.rebase_abort(cwd=worktree_dir)
|
|
105
|
+
raise RebaseConflictError(
|
|
106
|
+
f"Rebase of '{branch_name}' onto '{target_branch}' encountered conflicts.",
|
|
107
|
+
remediation=f"Resolve conflicts manually inside '{worktree_dir}' and run 'troven accept {clean_id}'.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# After rebase, retry fast-forward merge
|
|
111
|
+
if not git.merge_ff_only(branch_name, cwd=repo_root):
|
|
112
|
+
raise MergeConflictError(
|
|
113
|
+
f"Fast-forward merge still failed after rebase.",
|
|
114
|
+
remediation=f"Inspect branch '{branch_name}' in '{worktree_dir}'.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Update troven record status to accepted
|
|
118
|
+
record.status = "accepted"
|
|
119
|
+
record.resolved_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
120
|
+
saved_md = save_troven(repo_root, record)
|
|
121
|
+
|
|
122
|
+
# Squash update of .troven record to target branch
|
|
123
|
+
git.commit_files([saved_md], f"troven: accept {clean_id}", cwd=repo_root)
|
|
124
|
+
|
|
125
|
+
# Cleanup worktree and branch
|
|
126
|
+
if worktree_dir and worktree_dir.exists():
|
|
127
|
+
print(f"Removing worktree at {worktree_dir}...")
|
|
128
|
+
git.remove_worktree(worktree_dir, force=True, cwd=repo_root)
|
|
129
|
+
|
|
130
|
+
print(f"Deleting branch '{branch_name}'...")
|
|
131
|
+
git.delete_branch(branch_name, force=True, cwd=repo_root)
|
|
132
|
+
git.prune_worktrees(cwd=repo_root)
|
|
133
|
+
|
|
134
|
+
print(f"\n\033[32m[Success]\033[0m Troven '{clean_id}' accepted into '{target_branch}'.")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Implementation of 'troven create' subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from troven import git
|
|
12
|
+
from troven.errors import (
|
|
13
|
+
InvalidNameError,
|
|
14
|
+
TrovenError,
|
|
15
|
+
TrovenExistsError,
|
|
16
|
+
)
|
|
17
|
+
from troven.state import (
|
|
18
|
+
TrovenRecord,
|
|
19
|
+
get_troven_file,
|
|
20
|
+
load_troven,
|
|
21
|
+
save_troven,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_slug(name: str) -> None:
|
|
26
|
+
"""Ensure name is lowercase alphanumeric with dashes."""
|
|
27
|
+
if not name or not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", name):
|
|
28
|
+
raise InvalidNameError(
|
|
29
|
+
f"Invalid troven name: '{name}'.",
|
|
30
|
+
remediation="Use lowercase letters, numbers, and dashes (e.g. 'fast-lexer-simd').",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def execute_create(
|
|
35
|
+
name: str,
|
|
36
|
+
from_ref: str | None = None,
|
|
37
|
+
repo_url: str | None = None,
|
|
38
|
+
force: bool = False,
|
|
39
|
+
command: list[str] | None = None,
|
|
40
|
+
no_exec: bool = False,
|
|
41
|
+
) -> Path:
|
|
42
|
+
"""Create a new troven worktree and initialize change record."""
|
|
43
|
+
validate_slug(name)
|
|
44
|
+
|
|
45
|
+
today = date.today().isoformat()
|
|
46
|
+
troven_id = f"{today}-{name}"
|
|
47
|
+
|
|
48
|
+
# Handle --repo URL clone
|
|
49
|
+
if repo_url:
|
|
50
|
+
target_dir = Path.cwd() / name
|
|
51
|
+
if target_dir.exists():
|
|
52
|
+
raise TrovenError(f"Target directory {target_dir} already exists.")
|
|
53
|
+
print(f"Cloning {repo_url} into {target_dir}...")
|
|
54
|
+
git.clone_repo(repo_url, target_dir)
|
|
55
|
+
repo_root = target_dir
|
|
56
|
+
else:
|
|
57
|
+
repo_root = git.get_main_repo_root()
|
|
58
|
+
|
|
59
|
+
branch_name = f"troven/{troven_id}"
|
|
60
|
+
md_path = get_troven_file(repo_root, troven_id)
|
|
61
|
+
|
|
62
|
+
# Check for collisions
|
|
63
|
+
if md_path.exists():
|
|
64
|
+
raise TrovenExistsError(
|
|
65
|
+
f"Troven record '{troven_id}' already exists at {md_path}.",
|
|
66
|
+
remediation="Choose a different name or inspect the existing troven with 'troven show'.",
|
|
67
|
+
)
|
|
68
|
+
if git.branch_exists(branch_name, cwd=repo_root):
|
|
69
|
+
raise TrovenExistsError(
|
|
70
|
+
f"Git branch '{branch_name}' already exists.",
|
|
71
|
+
remediation="Delete or rename the conflicting branch, or choose a different troven name.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
parent_id: str | None = None
|
|
75
|
+
forked_from_id: str | None = None
|
|
76
|
+
resolved_start_point = "HEAD"
|
|
77
|
+
base_branch = git.get_current_branch(cwd=repo_root)
|
|
78
|
+
if base_branch == "HEAD":
|
|
79
|
+
base_branch = "master"
|
|
80
|
+
|
|
81
|
+
# Resolve --from argument
|
|
82
|
+
if from_ref:
|
|
83
|
+
# Check if from_ref is a troven ID
|
|
84
|
+
clean_from = from_ref.removeprefix("troven/")
|
|
85
|
+
archive_ref = f"refs/archive/troven/{clean_from}"
|
|
86
|
+
active_branch = f"troven/{clean_from}"
|
|
87
|
+
|
|
88
|
+
if git.ref_exists(archive_ref, cwd=repo_root):
|
|
89
|
+
if not force and sys.stdin.isatty():
|
|
90
|
+
ans = input(f"Troven '{clean_from}' was rejected. Create a new troven based on it? [y/N]: ").strip()
|
|
91
|
+
if ans.lower() not in ("y", "yes"):
|
|
92
|
+
print("Aborted.")
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
forked_from_id = clean_from
|
|
95
|
+
resolved_start_point = archive_ref
|
|
96
|
+
elif git.branch_exists(active_branch, cwd=repo_root):
|
|
97
|
+
if not force and sys.stdin.isatty():
|
|
98
|
+
ans = input(f"Troven '{clean_from}' is an active troven. Create a sub-troven from it? [y/N]: ").strip()
|
|
99
|
+
if ans.lower() not in ("y", "yes"):
|
|
100
|
+
print("Aborted.")
|
|
101
|
+
sys.exit(0)
|
|
102
|
+
parent_id = clean_from
|
|
103
|
+
resolved_start_point = active_branch
|
|
104
|
+
base_branch = active_branch
|
|
105
|
+
elif git.ref_exists(from_ref, cwd=repo_root) or git.branch_exists(from_ref, cwd=repo_root):
|
|
106
|
+
resolved_start_point = from_ref
|
|
107
|
+
base_branch = from_ref
|
|
108
|
+
else:
|
|
109
|
+
raise TrovenError(
|
|
110
|
+
f"Cannot resolve base ref: '{from_ref}'.",
|
|
111
|
+
remediation="Ensure the branch, commit, or troven ID exists.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
base_commit = git.get_head_sha(resolved_start_point, cwd=repo_root)
|
|
115
|
+
|
|
116
|
+
# Compute worktree directory
|
|
117
|
+
repo_name = repo_root.name
|
|
118
|
+
worktree_parent = repo_root.parent / f"{repo_name}-trovens"
|
|
119
|
+
worktree_dir = (worktree_parent / troven_id).resolve()
|
|
120
|
+
|
|
121
|
+
if worktree_dir.exists():
|
|
122
|
+
raise TrovenExistsError(
|
|
123
|
+
f"Worktree directory {worktree_dir} already exists.",
|
|
124
|
+
remediation="Remove the stale directory or pick a different name.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Create .troven/<id>.md record in base repo
|
|
128
|
+
record = TrovenRecord(
|
|
129
|
+
id=troven_id,
|
|
130
|
+
status="active",
|
|
131
|
+
version=1,
|
|
132
|
+
parent=parent_id,
|
|
133
|
+
forked_from=forked_from_id,
|
|
134
|
+
base_branch=base_branch,
|
|
135
|
+
base_commit=base_commit,
|
|
136
|
+
)
|
|
137
|
+
save_troven(repo_root, record)
|
|
138
|
+
|
|
139
|
+
# Commit the record to base branch if possible before branching
|
|
140
|
+
try:
|
|
141
|
+
git.commit_files([md_path], f"troven: start {troven_id}", cwd=repo_root)
|
|
142
|
+
if resolved_start_point == base_branch or resolved_start_point == "HEAD":
|
|
143
|
+
resolved_start_point = git.get_head_sha(base_branch, cwd=repo_root)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
# Create worktree
|
|
148
|
+
print(f"Creating worktree at {worktree_dir} on branch '{branch_name}'...")
|
|
149
|
+
git.add_worktree(
|
|
150
|
+
worktree_dir=worktree_dir,
|
|
151
|
+
branch_name=branch_name,
|
|
152
|
+
start_point=resolved_start_point,
|
|
153
|
+
cwd=repo_root,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Ensure worktree has the .troven file in place
|
|
157
|
+
save_troven(worktree_dir, record)
|
|
158
|
+
|
|
159
|
+
# Remote sync setup
|
|
160
|
+
git.configure_archive_remote(cwd=repo_root)
|
|
161
|
+
|
|
162
|
+
print(f"Troven '{troven_id}' initialized.")
|
|
163
|
+
|
|
164
|
+
if no_exec:
|
|
165
|
+
return worktree_dir
|
|
166
|
+
|
|
167
|
+
# Change into worktree and run command / agent
|
|
168
|
+
os.chdir(worktree_dir)
|
|
169
|
+
|
|
170
|
+
if command:
|
|
171
|
+
print(f"Executing: {' '.join(command)}")
|
|
172
|
+
os.execvp(command[0], command)
|
|
173
|
+
|
|
174
|
+
agent_cmd = os.environ.get("TROVEN_AGENT", os.environ.get("AGENT", "agy"))
|
|
175
|
+
boot_prompt = (
|
|
176
|
+
f"You are working in an isolated troven worktree for '{troven_id}' on branch '{branch_name}'.\n"
|
|
177
|
+
f"Troven outcome record: .troven/{troven_id}.md\n"
|
|
178
|
+
f"Please review README.md for project setup, building, and validation/test commands.\n"
|
|
179
|
+
f"When ready, write your summary to ## Summary in .troven/{troven_id}.md before calling 'troven accept' or 'troven reject'."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# If agy, pass prompt via -i
|
|
183
|
+
if agent_cmd == "agy":
|
|
184
|
+
os.execvp("agy", ["agy", "-i", boot_prompt])
|
|
185
|
+
else:
|
|
186
|
+
# Default fallback to executing agent or shell
|
|
187
|
+
try:
|
|
188
|
+
os.execvp(agent_cmd, [agent_cmd])
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
print(f"Agent '{agent_cmd}' not found in PATH. Dropping into bash shell in worktree.")
|
|
191
|
+
os.execvp("bash", ["bash"])
|
|
192
|
+
|
|
193
|
+
return worktree_dir
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Implementation of 'troven install-skill' subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_skill_content() -> str:
|
|
12
|
+
"""Read bundled SKILL.md content."""
|
|
13
|
+
try:
|
|
14
|
+
res = importlib.resources.files("troven").joinpath("skill/SKILL.md")
|
|
15
|
+
return res.read_text(encoding="utf-8")
|
|
16
|
+
except Exception:
|
|
17
|
+
# Fallback to local file if run from source tree
|
|
18
|
+
local_path = Path(__file__).resolve().parent.parent.parent.parent / "skills" / "troven" / "SKILL.md"
|
|
19
|
+
if local_path.exists():
|
|
20
|
+
return local_path.read_text(encoding="utf-8")
|
|
21
|
+
raise
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def execute_install_skill(user_only: bool = False) -> None:
|
|
25
|
+
"""Install the troven agent skill into agent skill discovery directories."""
|
|
26
|
+
content = get_skill_content()
|
|
27
|
+
|
|
28
|
+
home = Path.home()
|
|
29
|
+
agents_dir = home / ".agents" / "skills" / "troven"
|
|
30
|
+
gemini_dir = home / ".gemini" / "config" / "skills" / "troven"
|
|
31
|
+
|
|
32
|
+
# Install into ~/.agents/skills/troven/SKILL.md
|
|
33
|
+
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
(agents_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
|
35
|
+
print(f"\033[32m[Installed]\033[0m Skill written to {agents_dir / 'SKILL.md'}")
|
|
36
|
+
|
|
37
|
+
# Symlink or write to ~/.gemini/config/skills/troven
|
|
38
|
+
gemini_skills = home / ".gemini" / "config" / "skills"
|
|
39
|
+
if gemini_skills.exists():
|
|
40
|
+
if gemini_dir.is_symlink() or gemini_dir.exists():
|
|
41
|
+
if gemini_dir.is_symlink():
|
|
42
|
+
gemini_dir.unlink()
|
|
43
|
+
elif gemini_dir.is_dir():
|
|
44
|
+
shutil.rmtree(gemini_dir)
|
|
45
|
+
try:
|
|
46
|
+
gemini_dir.symlink_to(agents_dir, target_is_directory=True)
|
|
47
|
+
print(f"\033[32m[Linked]\033[0m Symlinked {gemini_dir} -> {agents_dir}")
|
|
48
|
+
except Exception:
|
|
49
|
+
gemini_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
(gemini_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
|
51
|
+
print(f"\033[32m[Installed]\033[0m Skill written to {gemini_dir / 'SKILL.md'}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Implementation of 'troven list' subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from troven import git
|
|
8
|
+
from troven.state import TrovenRecord, list_all_trovens
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_status(status: str) -> str:
|
|
12
|
+
if status == "accepted":
|
|
13
|
+
return "\033[32maccepted\033[0m"
|
|
14
|
+
elif status == "rejected":
|
|
15
|
+
return "\033[33mrejected\033[0m"
|
|
16
|
+
elif status == "active":
|
|
17
|
+
return "\033[36mactive \033[0m"
|
|
18
|
+
return status.ljust(8)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def execute_list(
|
|
22
|
+
status_filter: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List all trovens in a hierarchical tree view."""
|
|
25
|
+
repo_root = git.get_main_repo_root()
|
|
26
|
+
all_records = list_all_trovens(repo_root)
|
|
27
|
+
|
|
28
|
+
if not all_records:
|
|
29
|
+
print("No trovens found in this repository.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
# Filter if requested
|
|
33
|
+
records_by_id = {r.id: r for r in all_records}
|
|
34
|
+
|
|
35
|
+
# Build parent -> children map
|
|
36
|
+
children_map: dict[str | None, list[TrovenRecord]] = {}
|
|
37
|
+
for r in all_records:
|
|
38
|
+
parent_id = r.parent if r.parent in records_by_id else None
|
|
39
|
+
children_map.setdefault(parent_id, []).append(r)
|
|
40
|
+
|
|
41
|
+
# Check local worktrees
|
|
42
|
+
worktrees = git.list_worktrees(cwd=repo_root)
|
|
43
|
+
active_branches = {wt.branch for wt in worktrees if wt.branch}
|
|
44
|
+
active_paths = {wt.path for wt in worktrees}
|
|
45
|
+
|
|
46
|
+
def has_local_worktree(rec: TrovenRecord) -> bool:
|
|
47
|
+
branch = f"troven/{rec.id}"
|
|
48
|
+
if branch in active_branches:
|
|
49
|
+
return True
|
|
50
|
+
expected_path = repo_root.parent / f"{repo_root.name}-trovens" / rec.id
|
|
51
|
+
return expected_path.exists()
|
|
52
|
+
|
|
53
|
+
def print_node(rec: TrovenRecord, prefix: str = "", is_last: bool = True) -> None:
|
|
54
|
+
if status_filter and rec.status != status_filter:
|
|
55
|
+
# Still process children if filter doesn't match
|
|
56
|
+
pass
|
|
57
|
+
else:
|
|
58
|
+
connector = "└─ " if is_last else "├─ "
|
|
59
|
+
date_prefix = rec.created_at[:10] if rec.created_at else " "
|
|
60
|
+
status_str = format_status(rec.status)
|
|
61
|
+
|
|
62
|
+
wt_note = ""
|
|
63
|
+
if rec.status == "active" and not has_local_worktree(rec):
|
|
64
|
+
wt_note = " \033[90m[no local worktree]\033[0m"
|
|
65
|
+
|
|
66
|
+
ref_note = ""
|
|
67
|
+
if rec.status == "rejected" and rec.target_ref:
|
|
68
|
+
ref_note = f" \033[90m→ {rec.target_ref}\033[0m"
|
|
69
|
+
|
|
70
|
+
print(f"{prefix}{connector}{date_prefix} [{status_str}] {rec.id}{wt_note}{ref_note}")
|
|
71
|
+
|
|
72
|
+
children = children_map.get(rec.id, [])
|
|
73
|
+
new_prefix = prefix + (" " if is_last else "│ ")
|
|
74
|
+
for i, child in enumerate(children):
|
|
75
|
+
print_node(child, prefix=new_prefix, is_last=(i == len(children) - 1))
|
|
76
|
+
|
|
77
|
+
root_nodes = children_map.get(None, [])
|
|
78
|
+
for i, root in enumerate(root_nodes):
|
|
79
|
+
print_node(root, prefix="", is_last=(i == len(root_nodes) - 1))
|