taskmanager-exe 0.3.1__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.
- taskman/__init__.py +1 -0
- taskman/cli.py +93 -0
- taskman/core.py +579 -0
- taskman/jj.py +76 -0
- taskman/server.py +56 -0
- taskman/skills/SKILL.md +111 -0
- taskman/skills/complete.md +16 -0
- taskman/skills/continue.md +28 -0
- taskman/skills/describe.md +5 -0
- taskman/skills/handoff.md +43 -0
- taskman/skills/history-batch.md +7 -0
- taskman/skills/history-diffs.md +7 -0
- taskman/skills/history-search.md +13 -0
- taskman/skills/remember.md +8 -0
- taskman/skills/sync.md +5 -0
- taskman/skills/wt.md +9 -0
- taskmanager_exe-0.3.1.dist-info/METADATA +160 -0
- taskmanager_exe-0.3.1.dist-info/RECORD +21 -0
- taskmanager_exe-0.3.1.dist-info/WHEEL +4 -0
- taskmanager_exe-0.3.1.dist-info/entry_points.txt +2 -0
- taskmanager_exe-0.3.1.dist-info/licenses/LICENSE +3 -0
taskman/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TaskManager package."""
|
taskman/cli.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from taskman import core
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _get_version() -> str:
|
|
7
|
+
try:
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
return version("taskmanager-exe")
|
|
10
|
+
except Exception:
|
|
11
|
+
return "dev"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="taskman")
|
|
16
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {_get_version()}")
|
|
17
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
18
|
+
|
|
19
|
+
# Setup commands
|
|
20
|
+
subparsers.add_parser("init")
|
|
21
|
+
install_mcp = subparsers.add_parser("install-mcp")
|
|
22
|
+
install_mcp.add_argument("agent", choices=["claude", "cursor", "codex"])
|
|
23
|
+
install_skills = subparsers.add_parser("install-skills")
|
|
24
|
+
install_skills.add_argument("agent", choices=["claude", "codex"])
|
|
25
|
+
uninstall_mcp = subparsers.add_parser("uninstall-mcp")
|
|
26
|
+
uninstall_mcp.add_argument("agent", choices=["claude", "cursor", "codex"])
|
|
27
|
+
uninstall_skills = subparsers.add_parser("uninstall-skills")
|
|
28
|
+
uninstall_skills.add_argument("agent", choices=["claude", "codex"])
|
|
29
|
+
subparsers.add_parser("stdio")
|
|
30
|
+
|
|
31
|
+
wt_parser = subparsers.add_parser("wt")
|
|
32
|
+
wt_parser.add_argument("name", nargs="?", default=None,
|
|
33
|
+
help="worktree name (omit to just clone .agent-files in current dir)")
|
|
34
|
+
wt_parser.add_argument("--new", dest="new_branch", action="store_true",
|
|
35
|
+
help="create new branch instead of using existing one")
|
|
36
|
+
|
|
37
|
+
# Operation commands
|
|
38
|
+
desc = subparsers.add_parser("describe")
|
|
39
|
+
desc.add_argument("reason")
|
|
40
|
+
|
|
41
|
+
sy = subparsers.add_parser("sync")
|
|
42
|
+
sy.add_argument("reason")
|
|
43
|
+
|
|
44
|
+
hd = subparsers.add_parser("history-diffs")
|
|
45
|
+
hd.add_argument("file")
|
|
46
|
+
hd.add_argument("start_rev")
|
|
47
|
+
hd.add_argument("end_rev", nargs="?", default="@")
|
|
48
|
+
|
|
49
|
+
hb = subparsers.add_parser("history-batch")
|
|
50
|
+
hb.add_argument("file")
|
|
51
|
+
hb.add_argument("start_rev")
|
|
52
|
+
hb.add_argument("end_rev", nargs="?", default="@")
|
|
53
|
+
|
|
54
|
+
hs = subparsers.add_parser("history-search")
|
|
55
|
+
hs.add_argument("pattern")
|
|
56
|
+
hs.add_argument("--file", default=None)
|
|
57
|
+
hs.add_argument("--limit", type=int, default=20)
|
|
58
|
+
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
|
|
61
|
+
if args.command == "init":
|
|
62
|
+
print(core.init())
|
|
63
|
+
elif args.command == "wt":
|
|
64
|
+
print(core.wt(args.name, new_branch=args.new_branch))
|
|
65
|
+
elif args.command == "install-mcp":
|
|
66
|
+
print(core.install_mcp(args.agent))
|
|
67
|
+
elif args.command == "install-skills":
|
|
68
|
+
print(core.install_skills(args.agent))
|
|
69
|
+
elif args.command == "uninstall-mcp":
|
|
70
|
+
print(core.uninstall_mcp(args.agent))
|
|
71
|
+
elif args.command == "uninstall-skills":
|
|
72
|
+
print(core.uninstall_skills(args.agent))
|
|
73
|
+
elif args.command == "stdio":
|
|
74
|
+
from taskman.server import main as server_main
|
|
75
|
+
|
|
76
|
+
server_main()
|
|
77
|
+
elif args.command == "describe":
|
|
78
|
+
print(core.describe(args.reason))
|
|
79
|
+
elif args.command == "sync":
|
|
80
|
+
print(core.sync(args.reason))
|
|
81
|
+
elif args.command == "history-diffs":
|
|
82
|
+
print(core.history_diffs(args.file, args.start_rev, args.end_rev))
|
|
83
|
+
elif args.command == "history-batch":
|
|
84
|
+
print(core.history_batch(args.file, args.start_rev, args.end_rev))
|
|
85
|
+
elif args.command == "history-search":
|
|
86
|
+
print(core.history_search(args.pattern, args.file, args.limit))
|
|
87
|
+
else:
|
|
88
|
+
parser.print_help()
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
taskman/core.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tomllib
|
|
7
|
+
|
|
8
|
+
from taskman.jj import run_jj, find_agent_files_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run_cmd(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
|
12
|
+
proc = subprocess.run(
|
|
13
|
+
args,
|
|
14
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
15
|
+
text=True,
|
|
16
|
+
capture_output=True,
|
|
17
|
+
)
|
|
18
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_cmd_check(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
|
22
|
+
code, out, err = _run_cmd(args, cwd=cwd)
|
|
23
|
+
if code != 0:
|
|
24
|
+
cmd_str = " ".join(args)
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
f"command failed ({code}): {cmd_str}\nstdout:\n{out}\nstderr:\n{err}"
|
|
27
|
+
)
|
|
28
|
+
return code, out, err
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _agent_files_cwd() -> Path:
|
|
32
|
+
return find_agent_files_dir()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _current_rev_id(cwd: Path) -> str:
|
|
36
|
+
_, out, _ = run_jj(
|
|
37
|
+
["log", "--no-graph", "-r", "@", "-T", "change_id.short()"],
|
|
38
|
+
cwd,
|
|
39
|
+
)
|
|
40
|
+
return out.strip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_remote_main(cwd: Path) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
run_jj(["log", "-r", "main@origin", "--no-graph", "-T", "commit_id"], cwd)
|
|
46
|
+
except RuntimeError:
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_main_tracked(cwd: Path) -> bool:
|
|
52
|
+
"""Check if main@origin is tracked (linked to local main)."""
|
|
53
|
+
_, out, _ = run_jj(["bookmark", "list", "--all"], cwd)
|
|
54
|
+
# Tracked: "main: xyz abc" with "main@origin" on separate line
|
|
55
|
+
# Untracked: "main@origin [new] untracked"
|
|
56
|
+
for line in out.splitlines():
|
|
57
|
+
if "main@origin" in line and "untracked" in line:
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _setup_main_bookmark(cwd: Path) -> None:
|
|
63
|
+
"""Ensure main bookmark exists, tracks main@origin, and points to @."""
|
|
64
|
+
# Track main@origin if exists and untracked
|
|
65
|
+
if _has_remote_main(cwd) and not _is_main_tracked(cwd):
|
|
66
|
+
run_jj(["bookmark", "track", "main@origin"], cwd)
|
|
67
|
+
|
|
68
|
+
# Set main bookmark to current revision (creates if doesn't exist)
|
|
69
|
+
try:
|
|
70
|
+
run_jj(["bookmark", "set", "main", "-r", "@"], cwd)
|
|
71
|
+
except RuntimeError as exc:
|
|
72
|
+
# If bookmark doesn't exist, create it
|
|
73
|
+
if "no such bookmark" in str(exc).lower():
|
|
74
|
+
run_jj(["bookmark", "create", "main", "-r", "@"], cwd)
|
|
75
|
+
else:
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _status_has_conflicts(status_out: str) -> bool:
|
|
80
|
+
return bool(re.search(r"(?im)^(conflicts|conflicted)\b", status_out))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rev_list_for_revset(revset: str, cwd: Path) -> list[str]:
|
|
84
|
+
_, out, _ = run_jj(
|
|
85
|
+
["log", "--no-graph", "-r", revset, "-T", 'change_id.short() ++ "\\n"'],
|
|
86
|
+
cwd,
|
|
87
|
+
)
|
|
88
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _revset_has_revs(revset: str, cwd: Path) -> bool:
|
|
92
|
+
return bool(_rev_list_for_revset(revset, cwd))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _rev_list(start_rev: str, end_rev: str, cwd: Path) -> list[str]:
|
|
96
|
+
if _revset_has_revs(start_rev, cwd):
|
|
97
|
+
revset = f"{start_rev}::{end_rev}"
|
|
98
|
+
else:
|
|
99
|
+
revset = f"::{end_rev}"
|
|
100
|
+
return _rev_list_for_revset(revset, cwd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _escape_revset_value(value: str) -> str:
|
|
104
|
+
return value.replace("\\", "\\\\").replace('"', "\\\"")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def describe(reason: str) -> str:
|
|
108
|
+
"""Create named checkpoint.
|
|
109
|
+
|
|
110
|
+
1. jj status (trigger snapshot)
|
|
111
|
+
2. jj describe -m "<reason>"
|
|
112
|
+
|
|
113
|
+
Returns: Revision ID and confirmation
|
|
114
|
+
"""
|
|
115
|
+
cwd = _agent_files_cwd()
|
|
116
|
+
run_jj(["status"], cwd)
|
|
117
|
+
run_jj(["describe", "-m", reason], cwd)
|
|
118
|
+
rev = _current_rev_id(cwd)
|
|
119
|
+
return f"described {rev}: {reason}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sync(reason: str) -> str:
|
|
123
|
+
"""Full sync: describe, fetch, rebase, push.
|
|
124
|
+
|
|
125
|
+
1. jj describe -m "<reason>"
|
|
126
|
+
2. jj git fetch
|
|
127
|
+
3. jj rebase -d main@origin (skip if no remote branch)
|
|
128
|
+
4. Check jj status for conflicts -> return if conflicts
|
|
129
|
+
5. jj git push -> return error if rejected
|
|
130
|
+
|
|
131
|
+
Returns: Step-by-step status or conflict info
|
|
132
|
+
"""
|
|
133
|
+
cwd = _agent_files_cwd()
|
|
134
|
+
steps: list[str] = []
|
|
135
|
+
|
|
136
|
+
run_jj(["describe", "-m", reason], cwd)
|
|
137
|
+
rev = _current_rev_id(cwd)
|
|
138
|
+
steps.append(f"rev: {rev}")
|
|
139
|
+
|
|
140
|
+
run_jj(["git", "fetch"], cwd)
|
|
141
|
+
steps.append("git fetch: ok")
|
|
142
|
+
|
|
143
|
+
has_remote = _has_remote_main(cwd)
|
|
144
|
+
if has_remote:
|
|
145
|
+
run_jj(["rebase", "-d", "main@origin"], cwd)
|
|
146
|
+
steps.append("rebase: main@origin")
|
|
147
|
+
else:
|
|
148
|
+
steps.append("rebase: skipped (no main@origin)")
|
|
149
|
+
|
|
150
|
+
_, status_out, _ = run_jj(["status"], cwd)
|
|
151
|
+
if _status_has_conflicts(status_out):
|
|
152
|
+
return "conflicts detected:\n" + status_out
|
|
153
|
+
|
|
154
|
+
_setup_main_bookmark(cwd)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
# Use --all for first push (no remote yet), regular push otherwise
|
|
158
|
+
push_cmd = ["git", "push"] if has_remote else ["git", "push", "--all"]
|
|
159
|
+
run_jj(push_cmd, cwd)
|
|
160
|
+
steps.append("git push: ok")
|
|
161
|
+
except RuntimeError as exc:
|
|
162
|
+
err_msg = str(exc)
|
|
163
|
+
steps.append("git push: FAILED")
|
|
164
|
+
# Extract useful info from jj error
|
|
165
|
+
if "no author" in err_msg.lower() or "no committer" in err_msg.lower():
|
|
166
|
+
steps.append("Error: commit has no author/committer set")
|
|
167
|
+
steps.append("Fix: jj config set --user user.name 'Your Name'")
|
|
168
|
+
steps.append(" jj config set --user user.email 'you@example.com'")
|
|
169
|
+
elif "rejected" in err_msg.lower() or "non-fast-forward" in err_msg.lower():
|
|
170
|
+
steps.append("Error: push rejected (remote changed)")
|
|
171
|
+
steps.append("Recovery: run 'taskman sync' again to rebase and retry")
|
|
172
|
+
else:
|
|
173
|
+
steps.append(err_msg)
|
|
174
|
+
return "\n".join(steps)
|
|
175
|
+
|
|
176
|
+
return "\n".join(steps)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def history_diffs(file: str, start_rev: str, end_rev: str = "@") -> str:
|
|
180
|
+
"""Get all diffs for file across revision range.
|
|
181
|
+
|
|
182
|
+
1. Get revisions: jj log --no-graph -r "{start}::{end}" -T 'change_id.short()'
|
|
183
|
+
2. For each: jj diff -r {rev} -- {file}
|
|
184
|
+
3. Concatenate with === {rev} === headers
|
|
185
|
+
"""
|
|
186
|
+
cwd = _agent_files_cwd()
|
|
187
|
+
revs = _rev_list(start_rev, end_rev, cwd)
|
|
188
|
+
if not revs:
|
|
189
|
+
return "No revisions found in range."
|
|
190
|
+
|
|
191
|
+
sections: list[str] = []
|
|
192
|
+
for rev in revs:
|
|
193
|
+
sections.append(f"=== {rev} ===")
|
|
194
|
+
_, out, _ = run_jj(["diff", "-r", rev, "--", file], cwd)
|
|
195
|
+
sections.append(out.rstrip())
|
|
196
|
+
|
|
197
|
+
return "\n".join(sections).rstrip()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def history_batch(file: str, start_rev: str, end_rev: str = "@") -> str:
|
|
201
|
+
"""Fetch file content at all revisions in range.
|
|
202
|
+
|
|
203
|
+
1. Get revisions (same as history_diffs)
|
|
204
|
+
2. For each: jj file show -r {rev} {file}
|
|
205
|
+
3. Concatenate with === {rev} === headers
|
|
206
|
+
"""
|
|
207
|
+
cwd = _agent_files_cwd()
|
|
208
|
+
revs = _rev_list(start_rev, end_rev, cwd)
|
|
209
|
+
if not revs:
|
|
210
|
+
return "No revisions found in range."
|
|
211
|
+
|
|
212
|
+
sections: list[str] = []
|
|
213
|
+
for rev in revs:
|
|
214
|
+
try:
|
|
215
|
+
_, out, _ = run_jj(["file", "show", "-r", rev, file], cwd)
|
|
216
|
+
except RuntimeError as exc:
|
|
217
|
+
if "no such path" in str(exc).lower():
|
|
218
|
+
sections.append(f"=== {rev} ===")
|
|
219
|
+
sections.append("(file does not exist at this revision)")
|
|
220
|
+
continue
|
|
221
|
+
raise
|
|
222
|
+
sections.append(f"=== {rev} ===")
|
|
223
|
+
sections.append(out.rstrip())
|
|
224
|
+
|
|
225
|
+
return "\n".join(sections).rstrip()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def history_search(pattern: str, file: str | None = None, limit: int = 20) -> str:
|
|
229
|
+
"""Search history for pattern in diffs using jj's diff_contains().
|
|
230
|
+
|
|
231
|
+
Uses: jj log -r 'diff_contains("{pattern}")' --limit {limit}
|
|
232
|
+
Or with file: jj log -r 'diff_contains("{pattern}", "{file}")' --limit {limit}
|
|
233
|
+
|
|
234
|
+
Supports jj pattern syntax: exact:, glob:, regex:, substring:
|
|
235
|
+
Examples:
|
|
236
|
+
history_search("TODO") # glob (default)
|
|
237
|
+
history_search("regex:fix.*bug") # regex
|
|
238
|
+
history_search("exact:FIXME", "src/") # exact match in src/
|
|
239
|
+
|
|
240
|
+
Returns: Matching revisions with commit info
|
|
241
|
+
"""
|
|
242
|
+
cwd = _agent_files_cwd()
|
|
243
|
+
escaped_pattern = _escape_revset_value(pattern)
|
|
244
|
+
if file is None:
|
|
245
|
+
revset = f'diff_contains("{escaped_pattern}")'
|
|
246
|
+
else:
|
|
247
|
+
escaped_file = _escape_revset_value(file)
|
|
248
|
+
revset = f'diff_contains("{escaped_pattern}", "{escaped_file}")'
|
|
249
|
+
_, out, _ = run_jj(["log", "-r", revset, "--limit", str(limit)], cwd)
|
|
250
|
+
return out.rstrip()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# Setup functions
|
|
254
|
+
|
|
255
|
+
def init() -> str:
|
|
256
|
+
"""Create .agent-files.git/ (bare) + .agent-files/ (clone)
|
|
257
|
+
|
|
258
|
+
1. git init --bare .agent-files.git
|
|
259
|
+
2. jj git clone .agent-files.git .agent-files
|
|
260
|
+
3. Create initial files: STATUS.md, LONGTERM_MEM.md, MEDIUMTERM_MEM.md, tasks/
|
|
261
|
+
4. jj describe -m "initial setup" && jj git push
|
|
262
|
+
"""
|
|
263
|
+
cwd = Path.cwd()
|
|
264
|
+
bare = cwd / ".agent-files.git"
|
|
265
|
+
clone = cwd / ".agent-files"
|
|
266
|
+
|
|
267
|
+
if bare.exists() or clone.exists():
|
|
268
|
+
raise FileExistsError(".agent-files.git or .agent-files already exists")
|
|
269
|
+
|
|
270
|
+
_run_cmd_check(["git", "init", "--bare", str(bare)], cwd=cwd)
|
|
271
|
+
run_jj(["git", "clone", str(bare), str(clone)], cwd)
|
|
272
|
+
|
|
273
|
+
# Set default author for agent commits
|
|
274
|
+
run_jj(["config", "set", "--repo", "user.name", "Agent"], clone)
|
|
275
|
+
run_jj(["config", "set", "--repo", "user.email", "agent@localhost"], clone)
|
|
276
|
+
|
|
277
|
+
(clone / "tasks").mkdir(parents=True, exist_ok=True)
|
|
278
|
+
for filename in ["STATUS.md", "LONGTERM_MEM.md", "MEDIUMTERM_MEM.md"]:
|
|
279
|
+
path = clone / filename
|
|
280
|
+
path.touch(exist_ok=True)
|
|
281
|
+
|
|
282
|
+
run_jj(["describe", "-m", "initial setup"], clone)
|
|
283
|
+
run_jj(["bookmark", "create", "main", "-r", "@"], clone)
|
|
284
|
+
run_jj(["git", "push", "--all"], clone)
|
|
285
|
+
|
|
286
|
+
return "Initialized .agent-files.git and .agent-files"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _find_agent_files_git_dir(start: Path | None = None) -> Path:
|
|
290
|
+
current = Path.cwd() if start is None else Path(start)
|
|
291
|
+
if current.is_file():
|
|
292
|
+
current = current.parent
|
|
293
|
+
|
|
294
|
+
while True:
|
|
295
|
+
candidate = current / ".agent-files.git"
|
|
296
|
+
if candidate.is_dir():
|
|
297
|
+
return candidate
|
|
298
|
+
if current.parent == current:
|
|
299
|
+
break
|
|
300
|
+
current = current.parent
|
|
301
|
+
|
|
302
|
+
raise FileNotFoundError(".agent-files.git directory not found")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def wt(name: str | None = None, *, new_branch: bool = False) -> str:
|
|
306
|
+
"""Create git worktree and/or clone .agent-files
|
|
307
|
+
|
|
308
|
+
If name is provided (from main repo):
|
|
309
|
+
1. Create worktrees/<name>/ via git worktree add
|
|
310
|
+
2. Clone .agent-files into worktrees/<name>/
|
|
311
|
+
|
|
312
|
+
If name is None (recovery for existing worktree):
|
|
313
|
+
1. Clone .agent-files into current directory
|
|
314
|
+
|
|
315
|
+
By default uses existing branch. If new_branch=True, creates new branch.
|
|
316
|
+
"""
|
|
317
|
+
cwd = Path.cwd()
|
|
318
|
+
origin = _find_agent_files_git_dir(cwd)
|
|
319
|
+
in_main_repo = (cwd / ".agent-files.git").exists()
|
|
320
|
+
|
|
321
|
+
if name:
|
|
322
|
+
if not in_main_repo:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"Run 'taskman wt {name}' from main repo ({origin.parent})"
|
|
325
|
+
)
|
|
326
|
+
worktree_dir = cwd / "worktrees" / name
|
|
327
|
+
if worktree_dir.exists():
|
|
328
|
+
raise FileExistsError(f"worktrees/{name} already exists")
|
|
329
|
+
|
|
330
|
+
cmd = ["git", "worktree", "add", str(worktree_dir)]
|
|
331
|
+
if not new_branch:
|
|
332
|
+
cmd.append(name)
|
|
333
|
+
_run_cmd_check(cmd, cwd=cwd)
|
|
334
|
+
|
|
335
|
+
clone = worktree_dir / ".agent-files"
|
|
336
|
+
run_jj(["git", "clone", str(origin), str(clone)], worktree_dir)
|
|
337
|
+
run_jj(["config", "set", "--repo", "user.name", "Agent"], clone)
|
|
338
|
+
run_jj(["config", "set", "--repo", "user.email", "agent@localhost"], clone)
|
|
339
|
+
|
|
340
|
+
return f"Created worktree at worktrees/{name}/"
|
|
341
|
+
else:
|
|
342
|
+
if in_main_repo:
|
|
343
|
+
raise ValueError("Use 'taskman wt <name>' to create a worktree")
|
|
344
|
+
clone = cwd / ".agent-files"
|
|
345
|
+
if clone.exists():
|
|
346
|
+
raise FileExistsError(".agent-files already exists")
|
|
347
|
+
run_jj(["git", "clone", str(origin), str(clone)], cwd)
|
|
348
|
+
run_jj(["config", "set", "--repo", "user.name", "Agent"], clone)
|
|
349
|
+
run_jj(["config", "set", "--repo", "user.email", "agent@localhost"], clone)
|
|
350
|
+
return f"Cloned .agent-files from {origin}"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _load_json(path: Path) -> dict:
|
|
354
|
+
if not path.exists():
|
|
355
|
+
return {}
|
|
356
|
+
text = path.read_text(encoding="utf-8")
|
|
357
|
+
if not text.strip():
|
|
358
|
+
return {}
|
|
359
|
+
return json.loads(text)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
363
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _load_toml(path: Path) -> dict:
|
|
368
|
+
if not path.exists():
|
|
369
|
+
return {}
|
|
370
|
+
text = path.read_text(encoding="utf-8")
|
|
371
|
+
if not text.strip():
|
|
372
|
+
return {}
|
|
373
|
+
return tomllib.loads(text)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _toml_format_value(value) -> str:
|
|
377
|
+
if isinstance(value, str):
|
|
378
|
+
escaped = value.replace("\\", "\\\\").replace('"', "\\\"")
|
|
379
|
+
return f"\"{escaped}\""
|
|
380
|
+
if isinstance(value, bool):
|
|
381
|
+
return "true" if value else "false"
|
|
382
|
+
if isinstance(value, int):
|
|
383
|
+
return str(value)
|
|
384
|
+
if isinstance(value, float):
|
|
385
|
+
return repr(value)
|
|
386
|
+
if isinstance(value, list):
|
|
387
|
+
return "[" + ", ".join(_toml_format_value(v) for v in value) + "]"
|
|
388
|
+
raise TypeError(f"Unsupported TOML value: {value!r}")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _toml_dump_table(lines: list[str], prefix: list[str], table: dict) -> None:
|
|
392
|
+
lines.append(f"[{'.'.join(prefix)}]")
|
|
393
|
+
for key in sorted(table.keys()):
|
|
394
|
+
value = table[key]
|
|
395
|
+
if isinstance(value, dict):
|
|
396
|
+
continue
|
|
397
|
+
lines.append(f"{key} = {_toml_format_value(value)}")
|
|
398
|
+
|
|
399
|
+
for key in sorted(table.keys()):
|
|
400
|
+
value = table[key]
|
|
401
|
+
if isinstance(value, dict):
|
|
402
|
+
lines.append("")
|
|
403
|
+
_toml_dump_table(lines, prefix + [key], value)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _toml_dumps(data: dict) -> str:
|
|
407
|
+
lines: list[str] = []
|
|
408
|
+
|
|
409
|
+
for key in sorted(data.keys()):
|
|
410
|
+
value = data[key]
|
|
411
|
+
if isinstance(value, dict):
|
|
412
|
+
continue
|
|
413
|
+
lines.append(f"{key} = {_toml_format_value(value)}")
|
|
414
|
+
|
|
415
|
+
for key in sorted(data.keys()):
|
|
416
|
+
value = data[key]
|
|
417
|
+
if isinstance(value, dict):
|
|
418
|
+
if lines:
|
|
419
|
+
lines.append("")
|
|
420
|
+
_toml_dump_table(lines, [key], value)
|
|
421
|
+
|
|
422
|
+
if not lines:
|
|
423
|
+
return ""
|
|
424
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def install_mcp(agent: str) -> str:
|
|
428
|
+
"""Install MCP config for agent (claude, cursor, codex).
|
|
429
|
+
|
|
430
|
+
Config locations:
|
|
431
|
+
- claude: ~/.claude.json or .mcp.json (adds to mcpServers)
|
|
432
|
+
- cursor: ~/.cursor/mcp.json (adds to mcpServers)
|
|
433
|
+
- codex: ~/.codex/config.toml (adds to mcp_servers)
|
|
434
|
+
"""
|
|
435
|
+
home = Path.home()
|
|
436
|
+
if agent == "claude":
|
|
437
|
+
project_config = Path(".mcp.json")
|
|
438
|
+
path = project_config if project_config.exists() else home / ".claude.json"
|
|
439
|
+
data = _load_json(path)
|
|
440
|
+
data.setdefault("mcpServers", {})
|
|
441
|
+
data["mcpServers"]["taskman"] = {
|
|
442
|
+
"type": "stdio",
|
|
443
|
+
"command": "taskman",
|
|
444
|
+
"args": ["stdio"],
|
|
445
|
+
}
|
|
446
|
+
_write_json(path, data)
|
|
447
|
+
return f"Installed taskman MCP server in {path}"
|
|
448
|
+
|
|
449
|
+
if agent == "cursor":
|
|
450
|
+
project_config = Path(".cursor") / "mcp.json"
|
|
451
|
+
path = project_config if project_config.exists() else home / ".cursor" / "mcp.json"
|
|
452
|
+
data = _load_json(path)
|
|
453
|
+
data.setdefault("mcpServers", {})
|
|
454
|
+
data["mcpServers"]["taskman"] = {
|
|
455
|
+
"type": "stdio",
|
|
456
|
+
"command": "taskman",
|
|
457
|
+
"args": ["stdio"],
|
|
458
|
+
}
|
|
459
|
+
_write_json(path, data)
|
|
460
|
+
return f"Installed taskman MCP server in {path}"
|
|
461
|
+
|
|
462
|
+
if agent == "codex":
|
|
463
|
+
path = home / ".codex" / "config.toml"
|
|
464
|
+
data = _load_toml(path)
|
|
465
|
+
data.setdefault("mcp_servers", {})
|
|
466
|
+
data["mcp_servers"]["taskman"] = {
|
|
467
|
+
"command": "taskman",
|
|
468
|
+
"args": ["stdio"],
|
|
469
|
+
}
|
|
470
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
471
|
+
path.write_text(_toml_dumps(data), encoding="utf-8")
|
|
472
|
+
return f"Installed taskman MCP server in {path}"
|
|
473
|
+
|
|
474
|
+
raise ValueError(f"Unknown agent: {agent}")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def install_skills(agent: str) -> str:
|
|
478
|
+
"""Copy skill files to agent's skills directory."""
|
|
479
|
+
skills_dir = Path(__file__).resolve().parent / "skills"
|
|
480
|
+
if not skills_dir.is_dir():
|
|
481
|
+
raise FileNotFoundError(f"skills directory not found: {skills_dir}")
|
|
482
|
+
|
|
483
|
+
home = Path.home()
|
|
484
|
+
if agent == "claude":
|
|
485
|
+
dest_dir = home / ".claude" / "skills" / "taskman"
|
|
486
|
+
elif agent == "codex":
|
|
487
|
+
dest_dir = home / ".codex" / "skills" / "taskman"
|
|
488
|
+
else:
|
|
489
|
+
raise ValueError(f"Unknown agent: {agent}")
|
|
490
|
+
|
|
491
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
492
|
+
|
|
493
|
+
count = 0
|
|
494
|
+
for path in skills_dir.glob("*.md"):
|
|
495
|
+
shutil.copy2(path, dest_dir / path.name)
|
|
496
|
+
count += 1
|
|
497
|
+
|
|
498
|
+
return f"Installed {count} skills to {dest_dir}"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def uninstall_mcp(agent: str) -> str:
|
|
502
|
+
"""Remove MCP config for agent (claude, cursor, codex)."""
|
|
503
|
+
home = Path.home()
|
|
504
|
+
if agent == "claude":
|
|
505
|
+
project_config = Path(".mcp.json")
|
|
506
|
+
path = project_config if project_config.exists() else home / ".claude.json"
|
|
507
|
+
if not path.exists():
|
|
508
|
+
return f"No MCP config found at {path}"
|
|
509
|
+
data = _load_json(path)
|
|
510
|
+
servers = data.get("mcpServers")
|
|
511
|
+
if isinstance(servers, dict) and "taskman" in servers:
|
|
512
|
+
servers.pop("taskman", None)
|
|
513
|
+
if servers:
|
|
514
|
+
data["mcpServers"] = servers
|
|
515
|
+
else:
|
|
516
|
+
data.pop("mcpServers", None)
|
|
517
|
+
_write_json(path, data)
|
|
518
|
+
return f"Removed taskman MCP server from {path}"
|
|
519
|
+
return f"No taskman MCP server entry found in {path}"
|
|
520
|
+
|
|
521
|
+
if agent == "cursor":
|
|
522
|
+
project_config = Path(".cursor") / "mcp.json"
|
|
523
|
+
path = project_config if project_config.exists() else home / ".cursor" / "mcp.json"
|
|
524
|
+
if not path.exists():
|
|
525
|
+
return f"No MCP config found at {path}"
|
|
526
|
+
data = _load_json(path)
|
|
527
|
+
servers = data.get("mcpServers")
|
|
528
|
+
if isinstance(servers, dict) and "taskman" in servers:
|
|
529
|
+
servers.pop("taskman", None)
|
|
530
|
+
if servers:
|
|
531
|
+
data["mcpServers"] = servers
|
|
532
|
+
else:
|
|
533
|
+
data.pop("mcpServers", None)
|
|
534
|
+
_write_json(path, data)
|
|
535
|
+
return f"Removed taskman MCP server from {path}"
|
|
536
|
+
return f"No taskman MCP server entry found in {path}"
|
|
537
|
+
|
|
538
|
+
if agent == "codex":
|
|
539
|
+
path = home / ".codex" / "config.toml"
|
|
540
|
+
if not path.exists():
|
|
541
|
+
return f"No MCP config found at {path}"
|
|
542
|
+
data = _load_toml(path)
|
|
543
|
+
servers = data.get("mcp_servers")
|
|
544
|
+
if isinstance(servers, dict) and "taskman" in servers:
|
|
545
|
+
servers.pop("taskman", None)
|
|
546
|
+
if servers:
|
|
547
|
+
data["mcp_servers"] = servers
|
|
548
|
+
else:
|
|
549
|
+
data.pop("mcp_servers", None)
|
|
550
|
+
path.write_text(_toml_dumps(data), encoding="utf-8")
|
|
551
|
+
return f"Removed taskman MCP server from {path}"
|
|
552
|
+
return f"No taskman MCP server entry found in {path}"
|
|
553
|
+
|
|
554
|
+
raise ValueError(f"Unknown agent: {agent}")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def uninstall_skills(agent: str) -> str:
|
|
558
|
+
"""Remove taskman skill files from agent's skills directory."""
|
|
559
|
+
home = Path.home()
|
|
560
|
+
if agent == "claude":
|
|
561
|
+
dest_dir = home / ".claude" / "skills" / "taskman"
|
|
562
|
+
elif agent == "codex":
|
|
563
|
+
dest_dir = home / ".codex" / "skills" / "taskman"
|
|
564
|
+
else:
|
|
565
|
+
raise ValueError(f"Unknown agent: {agent}")
|
|
566
|
+
|
|
567
|
+
if not dest_dir.is_dir():
|
|
568
|
+
return f"No skills directory found at {dest_dir}"
|
|
569
|
+
|
|
570
|
+
count = 0
|
|
571
|
+
for path in dest_dir.glob("*.md"):
|
|
572
|
+
path.unlink()
|
|
573
|
+
count += 1
|
|
574
|
+
|
|
575
|
+
# Remove the taskman directory if empty
|
|
576
|
+
if dest_dir.is_dir() and not any(dest_dir.iterdir()):
|
|
577
|
+
dest_dir.rmdir()
|
|
578
|
+
|
|
579
|
+
return f"Removed {count} skills from {dest_dir}"
|
taskman/jj.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_jj(args: list[str], cwd: Path) -> tuple[int, str, str]:
|
|
7
|
+
"""Run jj command with git conflict style.
|
|
8
|
+
|
|
9
|
+
Uses --config-toml when supported, otherwise falls back to --config.
|
|
10
|
+
Uses subprocess.run() - no async needed for sequential CLI commands.
|
|
11
|
+
|
|
12
|
+
Returns: (returncode, stdout, stderr)
|
|
13
|
+
Raises: RuntimeError if returncode != 0
|
|
14
|
+
"""
|
|
15
|
+
cmd_toml = [
|
|
16
|
+
"jj",
|
|
17
|
+
"--config-toml",
|
|
18
|
+
'ui.conflict-marker-style = "git"',
|
|
19
|
+
*args,
|
|
20
|
+
]
|
|
21
|
+
proc = subprocess.run(
|
|
22
|
+
cmd_toml,
|
|
23
|
+
cwd=str(cwd),
|
|
24
|
+
text=True,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
)
|
|
27
|
+
if proc.returncode != 0 and "unexpected argument '--config-toml'" in proc.stderr:
|
|
28
|
+
cmd_legacy = [
|
|
29
|
+
"jj",
|
|
30
|
+
"--config",
|
|
31
|
+
"ui.conflict-marker-style=git",
|
|
32
|
+
*args,
|
|
33
|
+
]
|
|
34
|
+
proc = subprocess.run(
|
|
35
|
+
cmd_legacy,
|
|
36
|
+
cwd=str(cwd),
|
|
37
|
+
text=True,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
)
|
|
40
|
+
if proc.returncode != 0:
|
|
41
|
+
message = (
|
|
42
|
+
f"jj command failed ({proc.returncode}): {shlex.join(cmd_legacy)}\n"
|
|
43
|
+
f"stdout:\n{proc.stdout}\n"
|
|
44
|
+
f"stderr:\n{proc.stderr}"
|
|
45
|
+
)
|
|
46
|
+
raise RuntimeError(message)
|
|
47
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
48
|
+
|
|
49
|
+
if proc.returncode != 0:
|
|
50
|
+
message = (
|
|
51
|
+
f"jj command failed ({proc.returncode}): {shlex.join(cmd_toml)}\n"
|
|
52
|
+
f"stdout:\n{proc.stdout}\n"
|
|
53
|
+
f"stderr:\n{proc.stderr}"
|
|
54
|
+
)
|
|
55
|
+
raise RuntimeError(message)
|
|
56
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_agent_files_dir(start: Path | None = None) -> Path:
|
|
60
|
+
"""Search upward from start (default: cwd) to find .agent-files/
|
|
61
|
+
|
|
62
|
+
Returns: Path to .agent-files/ or raises FileNotFoundError
|
|
63
|
+
"""
|
|
64
|
+
current = Path.cwd() if start is None else Path(start)
|
|
65
|
+
if current.is_file():
|
|
66
|
+
current = current.parent
|
|
67
|
+
|
|
68
|
+
while True:
|
|
69
|
+
candidate = current / ".agent-files"
|
|
70
|
+
if candidate.is_dir():
|
|
71
|
+
return candidate
|
|
72
|
+
if current.parent == current:
|
|
73
|
+
break
|
|
74
|
+
current = current.parent
|
|
75
|
+
|
|
76
|
+
raise FileNotFoundError(".agent-files directory not found")
|
taskman/server.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from mcp.server.fastmcp import FastMCP
|
|
3
|
+
|
|
4
|
+
from taskman import core
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _SyncMCP:
|
|
8
|
+
def __init__(self, inner: FastMCP) -> None:
|
|
9
|
+
self._inner = inner
|
|
10
|
+
|
|
11
|
+
def list_tools(self):
|
|
12
|
+
return asyncio.run(self._inner.list_tools())
|
|
13
|
+
|
|
14
|
+
def __getattr__(self, name):
|
|
15
|
+
return getattr(self._inner, name)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
mcp = _SyncMCP(FastMCP("taskman"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@mcp.tool()
|
|
22
|
+
def describe(reason: str) -> str:
|
|
23
|
+
"""Create named checkpoint."""
|
|
24
|
+
return core.describe(reason)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@mcp.tool()
|
|
28
|
+
def sync(reason: str) -> str:
|
|
29
|
+
"""Full sync: describe, fetch, rebase, push."""
|
|
30
|
+
return core.sync(reason)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@mcp.tool()
|
|
34
|
+
def history_diffs(file: str, start_rev: str, end_rev: str = "@") -> str:
|
|
35
|
+
"""Get all diffs for file across revision range."""
|
|
36
|
+
return core.history_diffs(file, start_rev, end_rev)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@mcp.tool()
|
|
40
|
+
def history_batch(file: str, start_rev: str, end_rev: str = "@") -> str:
|
|
41
|
+
"""Fetch file content at all revisions in range."""
|
|
42
|
+
return core.history_batch(file, start_rev, end_rev)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def history_search(pattern: str, file: str | None = None, limit: int = 20) -> str:
|
|
47
|
+
"""Search history for pattern in diffs."""
|
|
48
|
+
return core.history_search(pattern, file, limit)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
mcp.run()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
taskman/skills/SKILL.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: taskman
|
|
3
|
+
description: Agent memory and task management CLI. Use this skill when you need to persist context across sessions, track tasks, hand off work, or store temporary agent scratch data. Provides the `taskman` CLI for init, sync, describe, and history operations.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Taskman
|
|
7
|
+
|
|
8
|
+
Version-controlled agent memory and task management. The `.agent-files/` directory is scratch space for ANY agent work that should persist across sessions - task tracking, memory, handoffs, notes, or temporary files.
|
|
9
|
+
|
|
10
|
+
## Structure
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
.agent-files/
|
|
14
|
+
STATUS.md # Task index, current session state
|
|
15
|
+
LONGTERM_MEM.md # Architecture knowledge (months+)
|
|
16
|
+
MEDIUMTERM_MEM.md # Patterns, gotchas (weeks)
|
|
17
|
+
tasks/
|
|
18
|
+
TASK_<slug>.md # Active tasks
|
|
19
|
+
_archive/ # Completed tasks
|
|
20
|
+
(any other scratch files)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**STATUS.md**: Operational state - task index, current focus, blockers, next steps. Update, don't overwrite.
|
|
24
|
+
|
|
25
|
+
**LONGTERM_MEM.md**: System architecture, component relationships. Rarely changes.
|
|
26
|
+
|
|
27
|
+
**MEDIUMTERM_MEM.md**: Reusable patterns and gotchas. NOT session logs.
|
|
28
|
+
|
|
29
|
+
**Task files**: One per user-facing work unit. Format:
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
# TASK: <title>
|
|
33
|
+
|
|
34
|
+
## Meta
|
|
35
|
+
Status: planned|in_progress|blocked|complete
|
|
36
|
+
Priority: P0|P1|P2
|
|
37
|
+
Created: YYYY-MM-DD
|
|
38
|
+
Completed: YYYY-MM-DD
|
|
39
|
+
|
|
40
|
+
## Problem
|
|
41
|
+
<what, why>
|
|
42
|
+
|
|
43
|
+
## Design
|
|
44
|
+
<decisions, alternatives rejected>
|
|
45
|
+
|
|
46
|
+
## Checklist
|
|
47
|
+
- [ ] item
|
|
48
|
+
- [x] completed item
|
|
49
|
+
|
|
50
|
+
## Attempts
|
|
51
|
+
### Attempt N (YYYY-MM-DD HH:MM)
|
|
52
|
+
Approach: ...
|
|
53
|
+
Result: ...
|
|
54
|
+
|
|
55
|
+
## Summary
|
|
56
|
+
Current state: ...
|
|
57
|
+
Key learnings: ...
|
|
58
|
+
Next steps: ...
|
|
59
|
+
|
|
60
|
+
## Notes
|
|
61
|
+
<breadcrumbs - pointers to recoverable info>
|
|
62
|
+
|
|
63
|
+
## Budget (optional)
|
|
64
|
+
Estimate: <tokens> (planning: X, impl: Y, validation: Z)
|
|
65
|
+
Variance: low|med|high
|
|
66
|
+
Intervention: autonomous|checkpoints|steering|collaborative
|
|
67
|
+
Spent: <tokens>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Budget uses tokens (measurable) not time. Variance = estimate spread (low=tight, high=wide). Intervention = human engagement pattern, not duration.
|
|
71
|
+
|
|
72
|
+
**Scratch space**: Store any temporary agent work here - it's version-controlled separately from the main repo.
|
|
73
|
+
|
|
74
|
+
## Progressive Disclosure
|
|
75
|
+
|
|
76
|
+
Store breadcrumbs (pointers), not content. Recover on-demand via Read/Bash/WebFetch.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
<slug>: <recovery-instruction>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Examples: `auth-flow: src/auth/login.ts:45-80` | `build-status: run `make build`` | `prev-attempt: jj diff -r @--`
|
|
83
|
+
|
|
84
|
+
Store inline only: decisions, key insights, non-reproducible errors.
|
|
85
|
+
|
|
86
|
+
See `/handoff` for writing breadcrumbs, `/continue` for expanding them.
|
|
87
|
+
|
|
88
|
+
## Commands
|
|
89
|
+
|
|
90
|
+
| Command | Use when |
|
|
91
|
+
|---------|----------|
|
|
92
|
+
| /continue | Resuming work from a previous session |
|
|
93
|
+
| /handoff | Saving context mid-task for next session |
|
|
94
|
+
| /remember | Persisting learnings to memory files |
|
|
95
|
+
| /complete | Finishing and archiving a task |
|
|
96
|
+
| /sync | Syncing .agent-files with origin |
|
|
97
|
+
| /describe | Creating a named checkpoint |
|
|
98
|
+
| /history-search | Searching history for patterns |
|
|
99
|
+
| /history-diffs | Viewing diffs across revisions |
|
|
100
|
+
| /history-batch | Fetching file content at revisions |
|
|
101
|
+
| /wt | Setting up .agent-files in a git worktree |
|
|
102
|
+
|
|
103
|
+
When a command is invoked, read the corresponding `.md` file in this skill directory for detailed instructions.
|
|
104
|
+
|
|
105
|
+
## jj Snapshotting
|
|
106
|
+
|
|
107
|
+
jj does NOT auto-snapshot on file changes alone. A jj command must be run to trigger a snapshot. Run `jj st` periodically (after edits or batches of edits) to capture history. Without this, intermediate states are lost.
|
|
108
|
+
|
|
109
|
+
## Important
|
|
110
|
+
|
|
111
|
+
`.agent-files/` should never be committed. Add it to `.gitignore`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Complete a task and archive it.
|
|
2
|
+
|
|
3
|
+
1. Update the task file:
|
|
4
|
+
- Set Status: complete
|
|
5
|
+
- Add Completed: date
|
|
6
|
+
- Fill Notes with any gotchas or uncompleted work
|
|
7
|
+
|
|
8
|
+
2. Move task to _archive/
|
|
9
|
+
|
|
10
|
+
3. Update STATUS.md:
|
|
11
|
+
- Remove from active tasks
|
|
12
|
+
- Add pointer to next task (if any)
|
|
13
|
+
|
|
14
|
+
4. Run: taskman sync "complete: $ARGUMENTS"
|
|
15
|
+
|
|
16
|
+
Keep it brief - the task is done.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Resume work from a previous session.
|
|
2
|
+
|
|
3
|
+
1. Run: taskman sync "continue"
|
|
4
|
+
|
|
5
|
+
2. Read STATUS.md - current focus, blockers, task index
|
|
6
|
+
|
|
7
|
+
3. Read the active task file(s) - focus on Summary and Notes sections
|
|
8
|
+
|
|
9
|
+
4. **Expand breadcrumbs selectively** (see below)
|
|
10
|
+
|
|
11
|
+
5. Ultrathink about your approach before continuing.
|
|
12
|
+
|
|
13
|
+
## Expanding Breadcrumbs
|
|
14
|
+
|
|
15
|
+
Task files contain pointers, not content. Expand only what's needed for your next step:
|
|
16
|
+
|
|
17
|
+
| Breadcrumb | Recovery |
|
|
18
|
+
|------------|----------|
|
|
19
|
+
| `src/auth.ts:45-80` | Read tool (those lines only) |
|
|
20
|
+
| run \`pytest -v\` | Bash tool (current state) |
|
|
21
|
+
| `jj diff -r @--` | Bash tool (last changes) |
|
|
22
|
+
| `issue: github.com/...` | WebFetch if needed |
|
|
23
|
+
|
|
24
|
+
**Order:** Read summary → identify next step → expand only what's needed → work → repeat.
|
|
25
|
+
|
|
26
|
+
Don't preload all references upfront. The previous session left good pointers - trust them and expand lazily.
|
|
27
|
+
|
|
28
|
+
**Ultrathink vs preloading:** Think deeply about *approach*, not by dumping all content into context. Expand breadcrumbs to answer specific questions, not "just in case".
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Mid-task handoff - save detailed context for next session.
|
|
2
|
+
|
|
3
|
+
1. Update the current task file with:
|
|
4
|
+
- Attempts: what was tried, what failed (brief - just approach + outcome)
|
|
5
|
+
- Summary: current state, key learnings, next steps
|
|
6
|
+
- Notes: **breadcrumbs only** - pointers to recoverable information
|
|
7
|
+
- Budget: update Spent tokens if tracking
|
|
8
|
+
|
|
9
|
+
2. Run: taskman sync "handoff: $ARGUMENTS"
|
|
10
|
+
|
|
11
|
+
3. Update STATUS.md with handoff context (brief pointer to task file)
|
|
12
|
+
|
|
13
|
+
## Breadcrumb Principle
|
|
14
|
+
|
|
15
|
+
**Store pointers, not content.** The next session can recover information on-demand.
|
|
16
|
+
|
|
17
|
+
Bad (context pollution):
|
|
18
|
+
```markdown
|
|
19
|
+
## Notes
|
|
20
|
+
The authentication flow works like this:
|
|
21
|
+
[50 lines of code]
|
|
22
|
+
The error message was:
|
|
23
|
+
[20 lines of stack trace]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Good (progressive disclosure):
|
|
27
|
+
```markdown
|
|
28
|
+
## Notes
|
|
29
|
+
auth-flow: src/auth/login.ts:45-80
|
|
30
|
+
error-repro: run `make test-auth` (fails on line 23)
|
|
31
|
+
prev-diff: jj diff -r @--
|
|
32
|
+
related-issue: github.com/org/repo/issues/123
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Writing Breadcrumbs
|
|
36
|
+
|
|
37
|
+
Format: `<slug>: <recovery-instruction> [(context)]`
|
|
38
|
+
|
|
39
|
+
Recovery: file→Read, command→Bash, url→WebFetch
|
|
40
|
+
|
|
41
|
+
**Store inline** (not as breadcrumbs): decisions, key insights, non-reproducible errors.
|
|
42
|
+
|
|
43
|
+
Goal: next session reconstructs context in 2-3 tool calls, not by reading walls of text.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Search history for a pattern in diffs.
|
|
2
|
+
|
|
3
|
+
Arguments: <pattern> [--file <file>] [--limit N]
|
|
4
|
+
|
|
5
|
+
Pattern syntax (jj native):
|
|
6
|
+
- Default: glob match
|
|
7
|
+
- regex:pattern - regex match
|
|
8
|
+
- exact:pattern - exact match
|
|
9
|
+
- substring:pattern - substring match
|
|
10
|
+
|
|
11
|
+
Run: taskman history-search $ARGUMENTS
|
|
12
|
+
|
|
13
|
+
Display matching revisions.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Persist context to memory files.
|
|
2
|
+
|
|
3
|
+
1. Update appropriate file based on $ARGUMENTS:
|
|
4
|
+
- MEDIUMTERM_MEM.md: patterns, gotchas, recent learnings
|
|
5
|
+
- LONGTERM_MEM.md: architecture, component relationships
|
|
6
|
+
- STATUS.md: current state, blockers, next steps
|
|
7
|
+
|
|
8
|
+
2. Run: taskman sync "remember: $ARGUMENTS"
|
taskman/skills/sync.md
ADDED
taskman/skills/wt.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Set up .agent-files in a git worktree.
|
|
2
|
+
|
|
3
|
+
Run: taskman wt $ARGUMENTS
|
|
4
|
+
|
|
5
|
+
- No arguments: clone .agent-files into current directory (for existing worktrees)
|
|
6
|
+
- `taskman wt <name>`: create worktree for existing branch <name>
|
|
7
|
+
- `taskman wt <name> --new`: create worktree + new branch at worktrees/<name>/
|
|
8
|
+
|
|
9
|
+
Use when working in a git worktree that doesn't have .agent-files yet.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taskmanager-exe
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Version-controlled task management for AI agents
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: mcp
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# TaskManager.exe
|
|
13
|
+
|
|
14
|
+
Version-controlled task management for AI agents. Agents use familiar file editing tools; versioning and sync happen transparently via [jj (jujutsu)](https://martinvonz.github.io/jj/).
|
|
15
|
+
|
|
16
|
+
## Problem
|
|
17
|
+
|
|
18
|
+
AI agents using file-based task systems lose work when:
|
|
19
|
+
- Multiple agents edit the same file
|
|
20
|
+
- Context resets mid-task and overwrites with stale state
|
|
21
|
+
- No history to recover from
|
|
22
|
+
- **Agents go in circles** - after context reset, they repeat mistakes because they don't know what was already tried
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Requires Python 3.11+ and [jj](https://martinvonz.github.io/jj/latest/install/).
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install taskmanager-exe
|
|
30
|
+
# or
|
|
31
|
+
uvx taskmanager-exe
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Initialize in your repo
|
|
38
|
+
taskman init
|
|
39
|
+
|
|
40
|
+
# Install MCP server config
|
|
41
|
+
taskman install-mcp claude # or: cursor, codex
|
|
42
|
+
|
|
43
|
+
# Install Claude Code skills (optional)
|
|
44
|
+
taskman install-skills
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To create a worktree (from main repo):
|
|
48
|
+
```bash
|
|
49
|
+
taskman wt my-feature # creates worktrees/my-feature/ + clones .agent-files
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To add .agent-files to an existing worktree (recovery):
|
|
53
|
+
```bash
|
|
54
|
+
taskman wt # clones .agent-files into current directory
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How It Works
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Agent
|
|
61
|
+
│
|
|
62
|
+
├── Edit tool ────────► .agent-files/ (jj repo)
|
|
63
|
+
│ (file ops) │
|
|
64
|
+
│ push/pull
|
|
65
|
+
├── MCP Server ───────────────┼──────────────────►
|
|
66
|
+
│ (batch/sync) ▼
|
|
67
|
+
│ .agent-files.git/ (bare origin)
|
|
68
|
+
└── Skills ───────────────────┘
|
|
69
|
+
(CLI wrapper)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- Agents edit files with their normal Edit tool
|
|
73
|
+
- jj auto-snapshots every change (no explicit commit needed)
|
|
74
|
+
- MCP tools or Skills handle sync and history queries
|
|
75
|
+
- Bare git origin serializes concurrent access across worktrees
|
|
76
|
+
|
|
77
|
+
## CLI Commands
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
taskman init # create .agent-files.git/ + .agent-files/
|
|
81
|
+
taskman wt <name> # create worktree (from main repo)
|
|
82
|
+
taskman wt # add .agent-files to existing worktree
|
|
83
|
+
taskman install-mcp <agent> # install MCP config (claude, cursor, codex)
|
|
84
|
+
taskman install-skills # install skill files to ~/.claude/commands/
|
|
85
|
+
taskman uninstall-mcp <agent> # remove MCP config
|
|
86
|
+
taskman uninstall-skills # remove skill files
|
|
87
|
+
|
|
88
|
+
taskman describe <reason> # create named checkpoint
|
|
89
|
+
taskman sync <reason> # full sync: describe + fetch + rebase + push
|
|
90
|
+
taskman history-diffs <file> <start> [end] # diffs across revision range
|
|
91
|
+
taskman history-batch <file> <start> [end] # file content at each revision
|
|
92
|
+
taskman history-search <pattern> [file] [limit] # search history
|
|
93
|
+
|
|
94
|
+
taskman stdio # run MCP server (stdio transport)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## MCP Tools
|
|
98
|
+
|
|
99
|
+
When installed via `taskman install-mcp`, these tools are available:
|
|
100
|
+
|
|
101
|
+
| Tool | Description |
|
|
102
|
+
|------|-------------|
|
|
103
|
+
| `describe(reason)` | Create named checkpoint |
|
|
104
|
+
| `sync(reason)` | Full sync workflow |
|
|
105
|
+
| `history_diffs(file, start, end)` | Aggregate diffs across range |
|
|
106
|
+
| `history_batch(file, start, end)` | File content at all revisions |
|
|
107
|
+
| `history_search(pattern, file, limit)` | Search history for pattern |
|
|
108
|
+
|
|
109
|
+
## Skills
|
|
110
|
+
|
|
111
|
+
When installed via `taskman install-skills`, these Claude Code skills are available:
|
|
112
|
+
|
|
113
|
+
| Skill | Description |
|
|
114
|
+
|-------|-------------|
|
|
115
|
+
| `/continue` | Resume work - pull + read STATUS.md |
|
|
116
|
+
| `/handoff` | Mid-task handoff - sync + detailed context |
|
|
117
|
+
| `/complete` | Task done - sync + archive |
|
|
118
|
+
| `/describe <reason>` | Create named checkpoint |
|
|
119
|
+
| `/sync <reason>` | Full sync workflow |
|
|
120
|
+
| `/history-diffs <file> <start> [end]` | Diffs across range |
|
|
121
|
+
| `/history-batch <file> <start> [end]` | File content at revisions |
|
|
122
|
+
| `/history-search <pattern> [--file] [--limit]` | Search history |
|
|
123
|
+
|
|
124
|
+
Skills wrap the CLI and work without MCP support.
|
|
125
|
+
|
|
126
|
+
## Direct jj Commands
|
|
127
|
+
|
|
128
|
+
Agents can also use jj directly for simple operations:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
jj status # current state
|
|
132
|
+
jj log # view history
|
|
133
|
+
jj diff # see changes
|
|
134
|
+
jj restore --from <rev> <file> # restore file from revision
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Task File Structure
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
.agent-files/
|
|
141
|
+
STATUS.md # Task index, session state
|
|
142
|
+
LONGTERM_MEM.md # Architecture (months+)
|
|
143
|
+
MEDIUMTERM_MEM.md # Patterns, gotchas (weeks)
|
|
144
|
+
tasks/
|
|
145
|
+
TASK_<slug>.md # Individual tasks
|
|
146
|
+
_archive/ # Completed tasks
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Sync Model
|
|
150
|
+
|
|
151
|
+
Sync at task boundaries:
|
|
152
|
+
- `/continue` - session start, pull latest state
|
|
153
|
+
- `/handoff` - mid-task, push with detailed context
|
|
154
|
+
- `/complete` - task done, push and archive
|
|
155
|
+
|
|
156
|
+
On conflict, agent resolves with Edit tool, then syncs again.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
taskman/__init__.py,sha256=_TflS09UJqi5AVigsi628jsaSPinpUxYDMBsis-kYaU,27
|
|
2
|
+
taskman/cli.py,sha256=NsadNr9OnngqOH_7nkR-6kDpYJ6kecjmbOWt9XF4eEw,3364
|
|
3
|
+
taskman/core.py,sha256=du1dhUipOqV7ook5H6fEFRM_g1XebBHBtzaSnJFPB_0,19422
|
|
4
|
+
taskman/jj.py,sha256=aF7W8MtxqbfRqVcmIAHVqEAW8S5B__u29QUOvlURipE,2267
|
|
5
|
+
taskman/server.py,sha256=5ACffRaSZRUuPpoaheiBsVqaMLacYZeRPc9erGJCyoE,1279
|
|
6
|
+
taskman/skills/SKILL.md,sha256=lvTofHeN4GwCsMmhjX5wZ_ZzanYhW1S4NtXFJB-MHpc,3467
|
|
7
|
+
taskman/skills/complete.md,sha256=2A5yZ2VowIvfdYkV0vSmYLa94HZJneTyQzmDmdjEG-U,360
|
|
8
|
+
taskman/skills/continue.md,sha256=aDXGWK9nP4xr9qiHzsvwuvS0osW90rMdM0GeMxL5M3s,1055
|
|
9
|
+
taskman/skills/describe.md,sha256=CICVO3o0C7Fj2Pdw_Y2RVwHc11JMtevExrGtY7uTSTI,104
|
|
10
|
+
taskman/skills/handoff.md,sha256=9LdtnLrSzVCkBNVYkawyUNzsoOMkh_c7N4UX1Kve9Fs,1262
|
|
11
|
+
taskman/skills/history-batch.md,sha256=_X_VS7mvsx6UZchXk1LaLBcu8O1OqNxH6RPBBtB_dTY,147
|
|
12
|
+
taskman/skills/history-diffs.md,sha256=3AiwkCJVnRJ-pdtbzY6uqxveW4eGjZNGOQfU6VgF5Tc,144
|
|
13
|
+
taskman/skills/history-search.md,sha256=StXlsDSEOZp4Z4sJzBhUcSqJz2kNPtEgvwRMwlF0i0k,307
|
|
14
|
+
taskman/skills/remember.md,sha256=47FLqmxR1nFKN6R7nlvV3ZyTd78acAQs-ZMqEHzF_JI,299
|
|
15
|
+
taskman/skills/sync.md,sha256=WYLiTzl_Rb4_2aWpmaW8sHYNWwOkMOTTPAQjGcFIt0M,150
|
|
16
|
+
taskman/skills/wt.md,sha256=N0-mCOl3z44q3R3XsXqmO6Mrm_CSSWuLu-WU1dOpSLs,368
|
|
17
|
+
taskmanager_exe-0.3.1.dist-info/METADATA,sha256=i8qsRndysDYwoHYKC7wIOJL7akcb-clXpCXKCEC82xQ,5034
|
|
18
|
+
taskmanager_exe-0.3.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
19
|
+
taskmanager_exe-0.3.1.dist-info/entry_points.txt,sha256=_S_GgMwhqTBXOPeevUXw0_Y7iOauQLIsLaC9bECTIy4,45
|
|
20
|
+
taskmanager_exe-0.3.1.dist-info/licenses/LICENSE,sha256=GrA0izm2N5Ifdndiu7vcBjKqIE4varKiDt3LLqY5wPA,164
|
|
21
|
+
taskmanager_exe-0.3.1.dist-info/RECORD,,
|