subtask-mcp 0.2.8__tar.gz → 0.2.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/PKG-INFO +2 -2
  2. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/README.md +1 -1
  3. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/__init__.py +1 -1
  4. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/cli.py +7 -0
  5. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/core.py +1 -0
  6. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/narrate.py +2 -2
  7. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/server.py +1 -1
  8. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/skill/SKILL.md +12 -0
  9. subtask_mcp-0.2.9/subtask/stephook.py +180 -0
  10. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/tools.json +1 -1
  11. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/PKG-INFO +2 -2
  12. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/SOURCES.txt +2 -0
  13. subtask_mcp-0.2.9/tests/test_stephook.py +89 -0
  14. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/LICENSE +0 -0
  15. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/pyproject.toml +0 -0
  16. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/setup.cfg +0 -0
  17. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/admission.py +0 -0
  18. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/capacity.py +0 -0
  19. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/config.py +0 -0
  20. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/doctor.py +0 -0
  21. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/__init__.py +0 -0
  22. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/base.py +0 -0
  23. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/claude_api.py +0 -0
  24. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/codex.py +0 -0
  25. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/demo.py +0 -0
  26. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/dsh.py +0 -0
  27. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/kimi.py +0 -0
  28. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/opencode.py +0 -0
  29. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/engines/registry.py +0 -0
  30. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/events.py +0 -0
  31. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/evidence.py +0 -0
  32. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/fastserver.py +0 -0
  33. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/gc.py +0 -0
  34. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/__init__.py +0 -0
  35. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/claude_plugin.py +0 -0
  36. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/clients.py +0 -0
  37. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/commands.py +0 -0
  38. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/doctor.py +0 -0
  39. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/fileops.py +0 -0
  40. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/installer.py +0 -0
  41. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/ownership.py +0 -0
  42. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/install/tomledit.py +0 -0
  43. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/jobs.py +0 -0
  44. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/ledger.py +0 -0
  45. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/policy.py +0 -0
  46. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/procid.py +0 -0
  47. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/progress.py +0 -0
  48. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/redact.py +0 -0
  49. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/roi.py +0 -0
  50. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/router.py +0 -0
  51. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/runners.py +0 -0
  52. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/schemacheck.py +0 -0
  53. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/telemetry.py +0 -0
  54. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask/worker.py +0 -0
  55. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/dependency_links.txt +0 -0
  56. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/entry_points.txt +0 -0
  57. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/requires.txt +0 -0
  58. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/top_level.txt +0 -0
  59. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_concurrency.py +0 -0
  60. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_engines.py +0 -0
  61. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_falsify.py +0 -0
  62. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_independent_client.py +0 -0
  63. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_install.py +0 -0
  64. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_live.py +0 -0
  65. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_protocol.py +0 -0
  66. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_routing_results.py +0 -0
  67. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_schema.py +0 -0
  68. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_security.py +0 -0
  69. {subtask_mcp-0.2.8 → subtask_mcp-0.2.9}/tests/test_subtask.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subtask-mcp
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/tedin7/subtask
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
70
70
  project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
71
71
 
72
72
  ```sh
73
- uv tool install "git+https://github.com/tedin7/subtask@v0.2.8" # or: uv tool install /path/to/checkout
73
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.9" # or: uv tool install /path/to/checkout
74
74
  subtask install --client all # prints the plan for every detected client
75
75
  subtask install --client all --yes # applies it: MCP server + skill
76
76
  subtask doctor --client all # checks app, config entry and a real handshake
@@ -39,7 +39,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
39
39
  project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
40
40
 
41
41
  ```sh
42
- uv tool install "git+https://github.com/tedin7/subtask@v0.2.8" # or: uv tool install /path/to/checkout
42
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.9" # or: uv tool install /path/to/checkout
43
43
  subtask install --client all # prints the plan for every detected client
44
44
  subtask install --client all --yes # applies it: MCP server + skill
45
45
  subtask doctor --client all # checks app, config entry and a real handshake
@@ -1,3 +1,3 @@
1
1
  """subtask: background sub-agents for coding agents."""
2
2
 
3
- __version__ = "0.2.8"
3
+ __version__ = "0.2.9"
@@ -65,6 +65,9 @@ def main() -> int:
65
65
  sp.add_argument("--how", required=True)
66
66
  sp.add_argument("--decision", action="append", dest="decisions")
67
67
  sub.add_parser("steps")
68
+ sub.add_parser(
69
+ "narrate-hook", help="Claude Code PreToolUse hook: automatic step announcement (reads JSON on stdin)"
70
+ )
68
71
  sub.add_parser("spend")
69
72
  sub.add_parser("engines")
70
73
  install_commands.add_parsers(sub)
@@ -83,6 +86,10 @@ def main() -> int:
83
86
  wa.add_argument("--interval", type=float, default=2.0)
84
87
  a = p.parse_args()
85
88
 
89
+ if a.cmd == "narrate-hook":
90
+ from . import stephook
91
+
92
+ return stephook.main()
86
93
  if a.cmd in ("install", "uninstall") or (a.cmd == "doctor" and a.client):
87
94
  return install_commands.run(a)
88
95
 
@@ -596,6 +596,7 @@ def step(
596
596
  out = f"STEP: {entry['title']}\n{explained}"
597
597
  entry["narrator"] = "model"
598
598
  entry["announcement"] = out
599
+ entry["epoch"] = time.time()
599
600
  config.HOME.mkdir(parents=True, exist_ok=True)
600
601
  with STEPS.open("a", encoding="utf-8") as fh:
601
602
  fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
@@ -81,7 +81,7 @@ def observed_activity(repo: str, limit: int = 5) -> str:
81
81
  return "\n".join(lines)
82
82
 
83
83
 
84
- def _call(spec: tuple[str, float, float], system: str, user: str) -> str:
84
+ def _call(spec: tuple[str, float, float], system: str, user: str, timeout: float = 60) -> str:
85
85
  """One Messages API call, booked in the ledger. Raises on any failure."""
86
86
  model, price_in, price_out = spec
87
87
  secrets = config.load_secrets()
@@ -112,7 +112,7 @@ def _call(spec: tuple[str, float, float], system: str, user: str) -> str:
112
112
  req = urllib.request.Request(
113
113
  "https://api.anthropic.com/v1/messages", data=json.dumps(body).encode(), headers=headers
114
114
  )
115
- with urllib.request.urlopen(req, timeout=60) as resp:
115
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
116
116
  data = json.load(resp)
117
117
  usage = data.get("usage", {})
118
118
  cost = (usage.get("input_tokens", 0) * price_in + usage.get("output_tokens", 0) * price_out) / 1e6
@@ -16,7 +16,7 @@ from . import core
16
16
  mcp = FastMCP(
17
17
  "subtask",
18
18
  instructions=(
19
- "Background sub-agents for parallel, long or review work; small edits do yourself. "
19
+ "Delegate long work to cheap engines; do small things yourself (measured: small tasks cost 2-3x). "
20
20
  "Pattern: ONE start_many([...]) with every job, then ONE wait(ids): it blocks and returns "
21
21
  "the summaries. Never poll status(); call wait() again only if it said 'still running'. "
22
22
  "result(id, full/evidence) only when the summary is not enough. "
@@ -17,6 +17,12 @@ Delegate when one of these is true:
17
17
  - complex code needs a reviewer who did not write it (concurrency, saved data
18
18
  or migrations, security, money, logic shared by many callers).
19
19
 
20
+ Measured (docs/benchmark.md): on small tasks delegating cost 2-3x the money
21
+ and time for the same result; on long, many-step pieces given to cheaper
22
+ engines (haiku-api, opencode-go, kimi) it cost 38-57 % less with about half
23
+ the parent context, but took 2-8x longer. So: delegate long work to cheap
24
+ engines; do small things yourself.
25
+
20
26
  Do NOT delegate when:
21
27
  - the change is small (a few lines, one file) or you already know the fix;
22
28
  - it needs what is in your context right now (a decision you just made with
@@ -73,6 +79,12 @@ Do NOT delegate when:
73
79
 
74
80
  ## Announce every significant step
75
81
 
82
+ With the Claude Code plugin installed, a hook announces steps automatically
83
+ before file changes and commits (at most every 20 minutes per repository),
84
+ shown only to the user: you spend no turn on it. Call `step` yourself only for
85
+ a decision that is not a file change (a choice of approach, a release).
86
+
87
+
76
88
  Before each significant step (new phase, choice of approach, a change with
77
89
  risk, a merge or release) call `step(title, path, how, decisions)` and show
78
90
  the returned text to the user verbatim. A separate cheap model reads your
@@ -0,0 +1,180 @@
1
+ """Automatic step announcements from a Claude Code PreToolUse hook, without the agent.
2
+
3
+ The agent does not have to call step(): before a file change or a commit, the hook
4
+ reads three facts and has the cheap narrator explain them to the user:
5
+
6
+ - the user's latest request (the "why"), from the session transcript;
7
+ - what the agent did so far (files read, commands run), from the transcript;
8
+ - the exact change about to happen (Edit/Write content, or the git command).
9
+
10
+ The explanation is returned as a hook `systemMessage`: shown to the user in the chat,
11
+ never sent to the agent, so the agent spends no turn and no token. At most once per
12
+ SUBTASK_STEP_EVERY_MIN minutes (default 20) per repository; a manual step() also counts.
13
+ Any failure lets the tool call through silently (logged in steps.jsonl).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+
24
+ EVERY_S = float(os.environ.get("SUBTASK_STEP_EVERY_MIN", "20")) * 60
25
+ TIMEOUT_S = float(os.environ.get("SUBTASK_STEP_HOOK_TIMEOUT_S", "10"))
26
+ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
27
+ MAX_CHANGE = 2500
28
+ SYSTEM = (
29
+ "You are an independent observer of a coding agent. You did not plan its work. "
30
+ "From the facts below, explain to the operator, who is smart but not technical, what is about "
31
+ "to happen and why: what the user asked, what the agent has done so far, what this change "
32
+ "does. Short sentences, plain words, no em dashes, at most 5 bullets. Base every statement on "
33
+ "the facts given; if the reason for the change is not clear from them, say so. If the change "
34
+ "does not fit the user's request, say it first. Write in this language: {lang}."
35
+ )
36
+
37
+
38
+ def _text(content: object) -> str:
39
+ if isinstance(content, str):
40
+ return content
41
+ if isinstance(content, list):
42
+ return " ".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
43
+ return ""
44
+
45
+
46
+ def read_transcript(path: str | None, max_bytes: int = 400_000) -> tuple[str, list[str]]:
47
+ """(last user request, recent agent actions) from a Claude Code session transcript."""
48
+ if not path or not Path(path).is_file():
49
+ return "", []
50
+ with open(path, "rb") as fh:
51
+ fh.seek(0, 2)
52
+ fh.seek(max(0, fh.tell() - max_bytes))
53
+ lines = fh.read().decode("utf-8", errors="replace").splitlines()
54
+ request, actions = "", []
55
+ for line in lines:
56
+ try:
57
+ row = json.loads(line)
58
+ except json.JSONDecodeError:
59
+ continue
60
+ msg = row.get("message") if isinstance(row, dict) else None
61
+ if not isinstance(msg, dict):
62
+ continue
63
+ content = msg.get("content")
64
+ if row.get("type") == "user":
65
+ is_tool_result = isinstance(content, list) and any(
66
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
67
+ )
68
+ text = _text(content).strip()
69
+ if text and not is_tool_result and not text.startswith("<"):
70
+ request, actions = text, [] # a new request starts a new chain of actions
71
+ elif row.get("type") == "assistant" and isinstance(content, list):
72
+ for b in content:
73
+ if isinstance(b, dict) and b.get("type") == "tool_use":
74
+ actions.append(_action(b.get("name", ""), b.get("input") or {}))
75
+ return request[-2000:], [a for a in actions if a][-12:]
76
+
77
+
78
+ def _action(name: str, args: dict) -> str:
79
+ target = args.get("file_path") or args.get("path") or args.get("pattern") or args.get("command") or ""
80
+ return f"{name} {str(target)[:120]}".strip()
81
+
82
+
83
+ def describe_change(tool: str, args: dict) -> str | None:
84
+ if tool in EDIT_TOOLS:
85
+ path = args.get("file_path") or args.get("notebook_path") or "?"
86
+ if tool == "Write":
87
+ body = f"new content:\n{str(args.get('content', ''))[:MAX_CHANGE]}"
88
+ elif tool == "MultiEdit":
89
+ parts = [
90
+ f"- {e.get('old_string', '')[:300]}\n+ {e.get('new_string', '')[:300]}"
91
+ for e in args.get("edits", [])
92
+ ]
93
+ body = "\n".join(parts)[:MAX_CHANGE]
94
+ else:
95
+ body = f"- {str(args.get('old_string', ''))[: MAX_CHANGE // 2]}\n+ {str(args.get('new_string', ''))[: MAX_CHANGE // 2]}"
96
+ return f"{tool} {path}\n{body}"
97
+ if tool == "Bash":
98
+ cmd = str(args.get("command", ""))
99
+ if "git commit" in cmd or "git merge" in cmd or "git push" in cmd:
100
+ return f"Bash: {cmd[:MAX_CHANGE]}"
101
+ return None
102
+
103
+
104
+ def _recent(repo: str) -> bool:
105
+ from .core import STEPS
106
+
107
+ if not STEPS.is_file():
108
+ return False
109
+ with STEPS.open("rb") as fh:
110
+ fh.seek(0, 2)
111
+ fh.seek(max(0, fh.tell() - 200_000))
112
+ lines = fh.read().decode("utf-8", errors="replace").splitlines()
113
+ now = time.time()
114
+ for line in reversed(lines):
115
+ try:
116
+ row = json.loads(line)
117
+ except json.JSONDecodeError:
118
+ continue
119
+ if row.get("repo") != repo:
120
+ continue
121
+ ts = row.get("epoch") or time.mktime(time.strptime(row["ts"], "%Y-%m-%d %H:%M:%S"))
122
+ return now - float(ts) < EVERY_S
123
+ return False
124
+
125
+
126
+ def _log(entry: dict) -> None:
127
+ from . import config
128
+ from .core import STEPS
129
+
130
+ config.HOME.mkdir(parents=True, exist_ok=True)
131
+ with STEPS.open("a", encoding="utf-8") as fh:
132
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
133
+
134
+
135
+ def handle(event: dict) -> dict | None:
136
+ """The hook's JSON answer, or None to stay silent."""
137
+ from . import narrate
138
+ from .core import _repo_of
139
+ from .redact import redact
140
+
141
+ change = describe_change(str(event.get("tool_name", "")), event.get("tool_input") or {})
142
+ if not change:
143
+ return None
144
+ args = event.get("tool_input") or {}
145
+ path = args.get("file_path") or args.get("notebook_path") or event.get("cwd") or os.getcwd()
146
+ repo = _repo_of(str(path)) or _repo_of(str(event.get("cwd") or "")) or str(event.get("cwd") or "")
147
+ if not repo or _recent(repo):
148
+ return None
149
+ request, actions = read_transcript(event.get("transcript_path"))
150
+ facts = (
151
+ f"User's latest request:\n{request or '(not found)'}\n\n"
152
+ f"Agent's recent actions:\n" + ("\n".join(actions) or "(none recorded)") + "\n\n"
153
+ f"Change about to happen:\n{change}\n\nRepository state:\n{narrate.repo_context(repo)}"
154
+ )
155
+ lang = os.environ.get("SUBTASK_STEP_LANG", "Italian")
156
+ entry = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "epoch": time.time(), "repo": repo, "source": "hook",
157
+ "title": change.splitlines()[0][:100], "path": str(path), "how": "automatic (PreToolUse hook)"} # fmt: skip
158
+ t0 = time.monotonic()
159
+ try:
160
+ text = narrate._call(narrate.EXPLAINER, SYSTEM.format(lang=lang), redact(facts), timeout=TIMEOUT_S)
161
+ except Exception as exc: # noqa: BLE001 - the work must never be blocked by the narrator
162
+ _log({**entry, "narrator": "failed", "error": f"{type(exc).__name__}: {exc}"[:200]})
163
+ return None
164
+ entry.update(narrator="model", narrator_ms=round((time.monotonic() - t0) * 1000), announcement=text)
165
+ _log(entry)
166
+ return {"systemMessage": f"STEP · {Path(repo).name}\n{text}", "suppressOutput": True}
167
+
168
+
169
+ def main() -> int:
170
+ try:
171
+ event = json.load(sys.stdin)
172
+ except (json.JSONDecodeError, ValueError):
173
+ return 0
174
+ try:
175
+ answer = handle(event) if isinstance(event, dict) else None
176
+ except Exception: # noqa: BLE001 - a hook must exit 0 so the tool call proceeds
177
+ answer = None
178
+ if answer:
179
+ print(json.dumps(answer, ensure_ascii=False))
180
+ return 0
@@ -1,5 +1,5 @@
1
1
  {
2
- "instructions": "Background sub-agents for parallel, long or review work; small edits do yourself. Pattern: ONE start_many([...]) with every job, then ONE wait(ids): it blocks and returns the summaries. Never poll status(); call wait() again only if it said 'still running'. result(id, full/evidence) only when the summary is not enough. Call step() before each significant step.",
2
+ "instructions": "Delegate long work to cheap engines; do small things yourself (measured: small tasks cost 2-3x). Pattern: ONE start_many([...]) with every job, then ONE wait(ids): it blocks and returns the summaries. Never poll status(); call wait() again only if it said 'still running'. result(id, full/evidence) only when the summary is not enough. Call step() before each significant step.",
3
3
  "tools": [
4
4
  {
5
5
  "name": "start",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subtask-mcp
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/tedin7/subtask
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
70
70
  project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
71
71
 
72
72
  ```sh
73
- uv tool install "git+https://github.com/tedin7/subtask@v0.2.8" # or: uv tool install /path/to/checkout
73
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.9" # or: uv tool install /path/to/checkout
74
74
  subtask install --client all # prints the plan for every detected client
75
75
  subtask install --client all --yes # applies it: MCP server + skill
76
76
  subtask doctor --client all # checks app, config entry and a real handshake
@@ -24,6 +24,7 @@ subtask/router.py
24
24
  subtask/runners.py
25
25
  subtask/schemacheck.py
26
26
  subtask/server.py
27
+ subtask/stephook.py
27
28
  subtask/telemetry.py
28
29
  subtask/tools.json
29
30
  subtask/worker.py
@@ -62,4 +63,5 @@ tests/test_protocol.py
62
63
  tests/test_routing_results.py
63
64
  tests/test_schema.py
64
65
  tests/test_security.py
66
+ tests/test_stephook.py
65
67
  tests/test_subtask.py
@@ -0,0 +1,89 @@
1
+ """Automatic step announcements from the PreToolUse hook: facts in, user-only message out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def _transcript(tmp_path):
11
+ rows = [
12
+ {"type": "user", "message": {"role": "user", "content": "rendi più veloce il conteggio dei job"}},
13
+ {"type": "assistant", "message": {"content": [
14
+ {"type": "tool_use", "name": "Read", "input": {"file_path": "/r/subtask/admission.py"}},
15
+ {"type": "tool_use", "name": "Bash", "input": {"command": "pytest -q tests/test_x.py"}}]}},
16
+ {"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "ok"}]}},
17
+ ] # fmt: skip
18
+ path = tmp_path / "t.jsonl"
19
+ path.write_text("\n".join(json.dumps(r) for r in rows))
20
+ return path
21
+
22
+
23
+ def _repo(tmp_path):
24
+ repo = tmp_path / "repo"
25
+ repo.mkdir()
26
+ subprocess.run(["git", "init", "-q", str(repo)], check=True)
27
+ return repo
28
+
29
+
30
+ def test_transcript_gives_request_and_actions_not_tool_results(tmp_path):
31
+ from subtask import stephook
32
+
33
+ request, actions = stephook.read_transcript(str(_transcript(tmp_path)))
34
+ assert request == "rendi più veloce il conteggio dei job"
35
+ assert actions == ["Read /r/subtask/admission.py", "Bash pytest -q tests/test_x.py"]
36
+
37
+
38
+ def test_only_changes_and_commits_are_announced():
39
+ from subtask import stephook
40
+
41
+ assert stephook.describe_change("Read", {"file_path": "x"}) is None
42
+ assert stephook.describe_change("Bash", {"command": "ls"}) is None
43
+ assert "git commit" in stephook.describe_change("Bash", {"command": "git commit -m x"})
44
+ edit = stephook.describe_change("Edit", {"file_path": "/a.py", "old_string": "a", "new_string": "b"})
45
+ assert edit.startswith("Edit /a.py") and "- a" in edit and "+ b" in edit
46
+
47
+
48
+ def test_hook_shows_the_explanation_to_the_user_once_per_window(home, tmp_path, monkeypatch):
49
+ from subtask import narrate, stephook
50
+
51
+ seen = {}
52
+
53
+ def fake_call(spec, system, user, timeout=60):
54
+ seen["user"] = user
55
+ return "- Hai chiesto un conteggio più veloce.\n- L'agente cambia admission.py."
56
+
57
+ monkeypatch.setattr(narrate, "_call", fake_call)
58
+ repo = _repo(tmp_path)
59
+ event = {"tool_name": "Edit", "tool_input": {"file_path": str(repo / "a.py"), "old_string": "x", "new_string": "y"},
60
+ "transcript_path": str(_transcript(tmp_path)), "cwd": str(repo)} # fmt: skip
61
+ out = stephook.handle(event)
62
+ assert out["systemMessage"].startswith("STEP · repo") and "conteggio" in out["systemMessage"]
63
+ assert "rendi più veloce" in seen["user"] and "+ y" in seen["user"]
64
+ assert stephook.handle(event) is None # inside the 20-minute window: silent, no second call
65
+
66
+
67
+ def test_narrator_failure_never_blocks_and_is_logged(home, tmp_path, monkeypatch):
68
+ from subtask import core, narrate, stephook
69
+
70
+ monkeypatch.setattr(narrate, "_call", lambda *a, **k: (_ for _ in ()).throw(TimeoutError("slow")))
71
+ repo = _repo(tmp_path)
72
+ event = {
73
+ "tool_name": "Write",
74
+ "tool_input": {"file_path": str(repo / "b.py"), "content": "z"},
75
+ "cwd": str(repo),
76
+ }
77
+ assert stephook.handle(event) is None
78
+ assert '"narrator": "failed"' in core.STEPS.read_text()
79
+
80
+
81
+ def test_cli_hook_exits_zero_and_prints_json_only(home, tmp_path):
82
+ repo = _repo(tmp_path)
83
+ event = {"tool_name": "Read", "tool_input": {"file_path": str(repo / "a.py")}, "cwd": str(repo)}
84
+ done = subprocess.run([sys.executable, "-m", "subtask.cli", "narrate-hook"], input=json.dumps(event),
85
+ capture_output=True, text=True, timeout=30, check=False) # fmt: skip
86
+ assert done.returncode == 0 and done.stdout == ""
87
+ bad = subprocess.run([sys.executable, "-m", "subtask.cli", "narrate-hook"], input="not json",
88
+ capture_output=True, text=True, timeout=30, check=False) # fmt: skip
89
+ assert bad.returncode == 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes