handoff-cli 0.3.0__tar.gz → 0.3.1__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 (50) hide show
  1. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/CLAUDE.md +17 -9
  2. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/PKG-INFO +1 -1
  3. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/README.md +17 -29
  4. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/README.zh-CN.md +17 -16
  5. handoff_cli-0.3.1/assets/handoff-hero.jpg +0 -0
  6. handoff_cli-0.3.1/cli/__init__.py +8 -0
  7. handoff_cli-0.3.1/cli/commands/new.py +88 -0
  8. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/run.py +81 -7
  9. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/core.py +110 -15
  10. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/jsonl_viewer.py +33 -5
  11. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/main.py +11 -6
  12. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-codex/SKILL.md +16 -10
  13. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-ds/SKILL.md +16 -10
  14. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-ds.toml +7 -7
  15. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/skills/handoff-opus/SKILL.md +16 -10
  16. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/user_config_template.yaml +12 -6
  17. {handoff_cli-0.3.0 → handoff_cli-0.3.1/docs}/TODO.md +2 -2
  18. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/docs/cli-reference.zh-CN.md +4 -20
  19. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/docs/design.zh-CN.md +1 -1
  20. handoff_cli-0.3.1/image-plan.md +60 -0
  21. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/pyproject.toml +1 -1
  22. handoff_cli-0.3.0/cli/__init__.py +0 -3
  23. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/.github/workflows/publish.yml +0 -0
  24. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/.gitignore +0 -0
  25. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/claude-code.jpg +0 -0
  26. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/codex.jpg +0 -0
  27. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/list-tui.jpg +0 -0
  28. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/parallel.jpg +0 -0
  29. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/shell.jpg +0 -0
  30. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/assets/tail.jpg +0 -0
  31. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/backend.py +0 -0
  32. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/backend_types.yaml +0 -0
  33. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/__init__.py +0 -0
  34. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/env.py +0 -0
  35. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/init.py +0 -0
  36. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/list.py +0 -0
  37. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/resume.py +0 -0
  38. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/commands/tail.py +0 -0
  39. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/config.py +0 -0
  40. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/jsonl_parser.py +0 -0
  41. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/stream.py +0 -0
  42. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/cli/tui.py +0 -0
  43. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/docs/configuration.zh-CN.md +0 -0
  44. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/ds-cli-tail-go-cli-commands-go-py-magical-toucan.md +0 -0
  45. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/00-overview.md +0 -0
  46. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/01-rename-packaging.md +0 -0
  47. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/02-multi-backend.md +0 -0
  48. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/03-skills-docs-cleanup.md +0 -0
  49. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/handoff/04-config-split-env.md +0 -0
  50. {handoff_cli-0.3.0 → handoff_cli-0.3.1}/plans/archive/readme-zh-cn-md-readme-md-screenshot-golden-church.md +0 -0
@@ -12,7 +12,14 @@ A CLI proxy that dispatches coding tasks to configurable AI backends — Claude
12
12
  # Install: uv tool install -e . then run init
13
13
  handoff --help
14
14
 
15
- # Dispatch a task
15
+ # Pre-allocate a run_id and get the canonical prompt file path
16
+ p=$(handoff new --backend deepseek --slug fix-auth)
17
+ cat > "$p" <<'EOF'
18
+ ...prompt...
19
+ EOF
20
+ handoff run --backend deepseek "$p"
21
+
22
+ # Other dispatch modes (allocate seq automatically)
16
23
  echo "Refactor X and add tests" | handoff run -
17
24
  handoff run --text "smoke test"
18
25
  handoff run --backend codex --pro - <<'EOF'
@@ -20,7 +27,7 @@ handoff run --backend codex --pro - <<'EOF'
20
27
  EOF
21
28
 
22
29
  # Browse/manage past runs
23
- handoff list # interactive TUI (curses) when stdout is a terminal
30
+ handoff list # interactive TUI (curses) when stdout is a terminal; `handoff ls` is an alias
24
31
  handoff resume <seq> # reopen a past conversation interactively in claude/codex
25
32
  handoff resume <seq> - # dispatch a follow-up task to that conversation (heredoc)
26
33
  handoff tail <run-id> # live-tail a run's output stream
@@ -40,7 +47,7 @@ There are no test suites or linting setup in this repo.
40
47
 
41
48
  ### Command dispatch (`cli/main.py`)
42
49
 
43
- `main()` parses `sys.argv[1]` and dispatches to the matching `cli/commands/<subcmd>.py`. Known commands: `run`, `list`, `resume`, `tail`, `env`, `init`. Before dispatching, `_migrate_legacy_state()` checks for a legacy `~/.ds-cli/` directory and renames it to `~/.handoff/` if the new directory doesn't exist yet. `env` and `init` do NOT initialize `Config`; other subcommands do (validates user config, creates DB).
50
+ `main()` parses `sys.argv[1]` and dispatches to the matching `cli/commands/<subcmd>.py`. Known commands: `run`, `new`, `list`/`ls`, `resume`, `tail`, `env`, `init`. Before dispatching, `_migrate_legacy_state()` checks for a legacy `~/.ds-cli/` directory and renames it to `~/.handoff/` if the new directory doesn't exist yet. `env` and `init` do NOT initialize `Config`; other subcommands do (validates user config, creates DB).
44
51
 
45
52
  ### Config (`cli/config.py`)
46
53
 
@@ -50,8 +57,9 @@ Two-layer split: `cli/backend_types.yaml` (mechanism) defines HOW each type is l
50
57
 
51
58
  All state lives under `~/.handoff/`:
52
59
  - `runs/handoff.db` — SQLite (WAL mode) with `runs` table (seq, run_id, uuid, session_id, cwd, prompt, jsonl_path, status, backend) and `run_counters` (daily auto-increment per day). `session_id` is the underlying conversation: equals `uuid` for a fresh run, or the parent's `session_id` for a `resume` continuation. `get_db()` performs in-place `ALTER TABLE` migrations to add `session_id` (backfilled from `uuid`) on old databases.
53
- - `tasks/` — per-run files: `{run_id}.prompt.txt`, `.out.txt` (progress), `.result.md` (final)
54
- - Run IDs: `hd-<MMDD>-<SEQ_CODE>` where SEQ_CODE is a 2-char encoding: `01`–`99` for 1–99, then `A0`–`ZZ` for 100–1035
60
+ - `tasks/` — per-run files: `{run_id}.prompt.md`, `.out.txt` (progress), `.result.md` (final)
61
+ - Run IDs: `<mmdd>-<backend2>-<SEQ_CODE>-<slug>` (e.g. `0611-ds-03-fix-auth`). `<backend2>` is a 2-char abbreviation: deepseek→ds, codex→cx, opus→op, others→first 2 chars. SEQ_CODE is a 2-char encoding: `01`–`99` for 1–99, then `A0`–`ZZ` for 100–1035. `<slug>` is ≤3 dash-separated lowercase words supplied by caller; automatic slugs: stdin→`from-stdin`, --text→`from-text`, external file→`from-file`, no-slug→`task`.
62
+ - `handoff new --backend <name> [--slug <slug>]`: pre-allocates a seq/run_id and prints the canonical `.prompt.md` path (does NOT create the file or DB row). Caller writes prompt to this path, then `handoff run <path>` adopts it.
55
63
  - Automatic migration: on startup, if `~/.ds-cli/` exists and `~/.handoff/` doesn't, the entire directory is renamed and `dscli.db` becomes `handoff.db`
56
64
 
57
65
  ### Backend resolution (`cli/backend.py`)
@@ -91,10 +99,10 @@ Textual-based interactive listing for `handoff list`. Renders a scrollable `Data
91
99
  ### Skill/subagent files
92
100
 
93
101
  All files live in `cli/skills/` (distributed with the wheel):
94
- - `handoff-ds/SKILL.md` — Claude Code skill for DeepSeek backend (`--backend deepseek`)
95
- - `handoff-codex/SKILL.md` — Claude Code skill for Codex backend (`--backend codex`)
96
- - `handoff-opus/SKILL.md` — Claude Code skill for Opus backend (`--backend opus`)
97
- - `handoff-ds.toml` — Codex subagent that forwards prompt files via `handoff run --backend deepseek <prompt-file> >/dev/null`
102
+ - `handoff-ds/SKILL.md` — Claude Code skill for DeepSeek backend (`--backend deepseek`). Protocol: `handoff new --backend deepseek --slug <slug>` → write prompt to returned path → `handoff run --backend deepseek <path>`
103
+ - `handoff-codex/SKILL.md` — Claude Code skill for Codex backend (`--backend codex`). Same protocol with `--backend codex`.
104
+ - `handoff-opus/SKILL.md` — Claude Code skill for Opus backend (`--backend opus`). Same protocol with `--backend opus`.
105
+ - `handoff-ds.toml` — Codex subagent. Caller runs `handoff new --backend deepseek --slug <slug>`, writes prompt to returned path, passes `PROMPT_FILE=<path>` to subagent; subagent runs `handoff run --backend deepseek <PROMPT_FILE> >/dev/null`
98
106
 
99
107
  `handoff init` creates hard/soft links from `cli/skills/` into `~/.codex/agents/` and `~/.claude/skills/`.
100
108
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: handoff-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Multi coding-agent task dispatcher — a CLI proxy for claude that sends coding tasks to configurable AI backends
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: pyyaml<7,>=6
@@ -1,23 +1,23 @@
1
1
  <div align="center">
2
2
 
3
3
  # handoff
4
+ **Your coding agents should be collaborating with each other.**
4
5
 
5
- **Your coding agents should be delegating to each other.**
6
6
 
7
- Hand work off to DeepSeek from inside Claude Code / Codex and save your flagship quota;
8
- hand a hard problem off to Codex / Opus from inside a DeepSeek session and borrow a brain.
9
- No tool-switching, no lost context.
7
+ <img src="assets/handoff-hero.jpg" width="100%" alt="hero">
8
+
10
9
 
11
- [![PyPI](https://img.shields.io/pypi/v/handoff-cli)](https://pypi.org/project/handoff-cli/)
12
- [![Python](https://img.shields.io/pypi/pyversions/handoff-cli)](https://pypi.org/project/handoff-cli/)
10
+ | From | → Hand off to | Why |
11
+ | :-- | :-- | :-- |
12
+ | Claude Code / Codex | **DeepSeek** | Execution work is fast and cheap; save flagship quota for decisions |
13
+ | DeepSeek | **Codex / Opus** | Borrow a brain for hard problems, bring the answer back to your session |
14
+
15
+ No tool-switching, no lost context.
13
16
 
14
17
  **English** · [简体中文](README.zh-CN.md)
15
18
 
16
19
  </div>
17
20
 
18
- <!-- assets/claude-code.jpg — ~720px wide — hero shot: one-sentence dispatch in Claude Code, main session only echoes RESULT=, agent reads .result.md and reports back -->
19
- <img src="assets/claude-code.jpg" width="720" alt="Handing a task off to DeepSeek from Claude Code">
20
-
21
21
  ## Why handoff
22
22
 
23
23
  If you juggle more than one coding agent, these will sound familiar:
@@ -63,15 +63,16 @@ handoff init
63
63
 
64
64
  ## Who you can hand work off to
65
65
 
66
- | Prompt / agent | Delegates to | Under the hood | Best for |
66
+ | What you say | From | Hands off to | Best for |
67
67
  | --- | --- | --- | --- |
68
- | `/handoff-ds` | DeepSeek V4 | `claude -p` (DeepSeek's Anthropic-compatible endpoint) | Execution work: writing code, running tests, refactors, bulk edits |
69
- | `/handoff-codex` | Codex (GPT-5.5) | `codex exec` | Heavy reasoning, second opinions, gnarly debugging |
70
- | `/handoff-opus` | Claude Opus | `claude -p` | Decisions that deserve the top model |
68
+ | `/handoff-ds` | Claude Code | DeepSeek V4 | Execution work: writing code, running tests, refactors, bulk edits |
69
+ | `handoff-ds` (subagent) | Codex | DeepSeek V4 | Same as above use this when you're inside Codex |
70
+ | `/handoff-codex` | Claude Code | Codex (GPT-5.5) | Heavy reasoning, second opinions, gnarly debugging |
71
+ | `/handoff-opus` | Claude Code | Claude Opus | Decisions that deserve the top model |
71
72
 
72
- > Codex has no slash commands; use the subagent of the same name instead — say "have `handoff-ds` execute the task above."
73
+ > Codex has no slash commands, so that row is the subagent of the same name — say "have `handoff-ds` execute the task above."
73
74
 
74
- All three targets work out of the box: opus / codex reuse your local logins with zero config; deepseek only needs a token.
75
+ All three targets work out of the box: opus / codex reuse your local logins with zero config; deepseek only needs a token. Under the hood they run `claude -p` (deepseek via its Anthropic-compatible endpoint, opus via your local login) and `codex exec`.
75
76
 
76
77
  ## After a task is dispatched
77
78
 
@@ -81,7 +82,7 @@ Dispatching and resuming are the AI's job (`handoff run` / `handoff resume` unde
81
82
  <tr>
82
83
  <td width="50%" valign="top">
83
84
 
84
- **`handoff list`** — interactive TUI over your full task history. Read the full prompt, live status, and final result; press `G` on a row to reload that conversation and keep chatting.
85
+ **`handoff list` / `handoff ls`** — interactive TUI over your full task history. Read the full prompt, live status, and final result; press `G` on a row to reload that conversation and keep chatting.
85
86
 
86
87
  </td>
87
88
  <td width="50%" valign="top">
@@ -170,19 +171,6 @@ backends:
170
171
  ...
171
172
  ```
172
173
 
173
- Want another Anthropic-compatible endpoint? Add a block under `backends`:
174
-
175
- ```yaml
176
- backends:
177
- kimi:
178
- type: claude
179
- model: kimi-k3
180
- env:
181
- ANTHROPIC_BASE_URL: https://api.moonshot.cn/anthropic
182
- ANTHROPIC_AUTH_TOKEN: "${MOONSHOT_API_KEY}"
183
- ANTHROPIC_MODEL: "{model}"
184
- ```
185
-
186
174
  The env block is entirely yours — every key=value you set is exported before the CLI launches. `{model}` substitutes the resolved model name, `${ENV_VAR}` expands from your shell.
187
175
 
188
176
  Run `handoff env` to see where everything lives. Full details: **[configuration docs →](docs/configuration.zh-CN.md)**.
@@ -1,23 +1,23 @@
1
1
  <div align="center">
2
2
 
3
3
  # handoff
4
+ **你的 coding agent 们,该互相协作了。**
4
5
 
5
- **你的 coding agent 们,该互相派活了。**
6
6
 
7
- Claude Code / Codex 里把活儿 handoff DeepSeek,省下旗舰额度;
8
- 在 DeepSeek 会话里把难题 handoff 给 Codex / Opus,借个脑子。
9
- 不用切来切去,也不丢上下文。
7
+ <img src="assets/handoff-hero.jpg" width="100%" alt="hero">
8
+
10
9
 
11
- [![PyPI](https://img.shields.io/pypi/v/handoff-cli)](https://pypi.org/project/handoff-cli/)
12
- [![Python](https://img.shields.io/pypi/pyversions/handoff-cli)](https://pypi.org/project/handoff-cli/)
10
+ | 从 | → 派给 | 为什么 |
11
+ | :-- | :-- | :-- |
12
+ | Claude Code / Codex | **DeepSeek** | 执行性工作又快又便宜,旗舰额度留给决策 |
13
+ | DeepSeek | **Codex / Opus** | 难题借个脑子,答案带回当前会话 |
14
+
15
+ 不用切来切去,也不丢上下文。
13
16
 
14
17
  [English](README.md) · **简体中文**
15
18
 
16
19
  </div>
17
20
 
18
- <!-- assets/claude-code.jpg — 建议 720 宽 — 主演示图:Claude Code 里一句话派发,主会话只回显 RESULT=,完成后读 .result.md 汇报 -->
19
- <img src="assets/claude-code.jpg" width="720" alt="在 Claude Code 里把任务 handoff 给 DeepSeek">
20
-
21
21
  ## 为什么需要 handoff
22
22
 
23
23
  如果你同时用着几家 coding agent,这些场景你一定眼熟:
@@ -63,15 +63,16 @@ handoff init
63
63
 
64
64
  ## 可以把活儿派给谁
65
65
 
66
- | 提示词 / agent | 派给谁 | 底层 | 适合 |
66
+ | 你怎么说 | | 派给 | 适合 |
67
67
  | --- | --- | --- | --- |
68
- | `/handoff-ds` | DeepSeek V4 | `claude -p`(DeepSeek Anthropic 端点) | 写代码、跑测试、重构、批量修改等执行性工作 |
69
- | `/handoff-codex` | Codex (GPT-5.5) | `codex exec` | 复杂推理、第二意见、疑难调试 |
70
- | `/handoff-opus` | Claude Opus | `claude -p` | 需要顶级模型出马的关键决策 |
68
+ | `/handoff-ds` | Claude Code | DeepSeek V4 | 写代码、跑测试、重构、批量修改等执行性工作 |
69
+ | `handoff-ds`(subagent) | Codex | DeepSeek V4 | 同上——你人在 Codex 里时走这条 |
70
+ | `/handoff-codex` | Claude Code | Codex (GPT-5.5) | 复杂推理、第二意见、疑难调试 |
71
+ | `/handoff-opus` | Claude Code | Claude Opus | 需要顶级模型出马的关键决策 |
71
72
 
72
- > Codex 里没有 slash 命令,对应的是同名 subagent:说「让 `handoff-ds` 执行上述任务」即可。
73
+ > Codex 里没有 slash 命令,所以那行是同名 subagent:说「让 `handoff-ds` 执行上述任务」即可。
73
74
 
74
- 三个目标开箱即用:opus / codex 走你本机的登录态,零配置;deepseek 只需 token
75
+ 三个目标开箱即用:opus / codex 走你本机的登录态,零配置;deepseek 只需 token。底层分别是 `claude -p`(deepseek 走其 Anthropic 端点,opus 走本机登录态)和 `codex exec`。
75
76
 
76
77
  ## 任务派出去之后
77
78
 
@@ -81,7 +82,7 @@ handoff init
81
82
  <tr>
82
83
  <td width="50%" valign="top">
83
84
 
84
- **`handoff list`** — 交互式 TUI,浏览全部历史任务。看 prompt 全文、实时状态、最终结果;选中按 `G` 直接把那次会话重新加载进来接着聊。
85
+ **`handoff list` / `handoff ls`** — 交互式 TUI,浏览全部历史任务。看 prompt 全文、实时状态、最终结果;选中按 `G` 直接把那次会话重新加载进来接着聊。
85
86
 
86
87
  </td>
87
88
  <td width="50%" valign="top">
@@ -0,0 +1,8 @@
1
+ """handoff CLI package."""
2
+
3
+ try:
4
+ from importlib.metadata import version as _ver
5
+
6
+ __version__ = _ver("handoff-cli")
7
+ except Exception:
8
+ __version__ = "0.0.0"
@@ -0,0 +1,88 @@
1
+ """handoff new command.
2
+
3
+ Pre-allocates a run_id and returns the canonical .prompt.md path so the caller
4
+ can write the prompt directly to its final archive location before dispatching.
5
+
6
+ Usage:
7
+ handoff new --backend <name> [--slug <slug>]
8
+
9
+ Stdout: one line — absolute path to the .prompt.md file (file is NOT created).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+ import datetime
17
+
18
+ from ..core import (
19
+ get_db,
20
+ alloc_seq,
21
+ backend_abbrev,
22
+ slug_clean,
23
+ TASKS_DIR,
24
+ )
25
+ from ..config import Config
26
+
27
+
28
+ def cmd_new(argv: list[str], config: Config):
29
+ """handoff new --backend <name> [--slug <slug>]"""
30
+ backend_arg = ""
31
+ slug_arg = ""
32
+
33
+ i = 0
34
+ while i < len(argv):
35
+ a = argv[i]
36
+ if a == "--backend":
37
+ i += 1
38
+ if i >= len(argv):
39
+ print("handoff new: --backend requires a value", file=sys.stderr)
40
+ sys.exit(2)
41
+ backend_arg = argv[i]
42
+ elif a.startswith("--backend="):
43
+ backend_arg = a.split("=", 1)[1]
44
+ elif a == "--slug":
45
+ i += 1
46
+ if i >= len(argv):
47
+ print("handoff new: --slug requires a value", file=sys.stderr)
48
+ sys.exit(2)
49
+ slug_arg = argv[i]
50
+ elif a.startswith("--slug="):
51
+ slug_arg = a.split("=", 1)[1]
52
+ elif a in ("-h", "--help"):
53
+ from ..main import usage
54
+ usage()
55
+ sys.exit(0)
56
+ elif a.startswith("-"):
57
+ print(f"handoff new: unknown option {a}", file=sys.stderr)
58
+ sys.exit(2)
59
+ i += 1
60
+
61
+ backend_name = backend_arg or config.default_backend
62
+ if not backend_name:
63
+ print("handoff new: --backend is required (or set a default backend in config)", file=sys.stderr)
64
+ sys.exit(2)
65
+
66
+ # Validate backend exists in config
67
+ backend_cfg = config.get_backend(backend_name)
68
+ if not backend_cfg:
69
+ print(
70
+ f"handoff new: unknown backend '{backend_name}'. "
71
+ f"Available: {', '.join(sorted(config.backends.keys()))}",
72
+ file=sys.stderr,
73
+ )
74
+ sys.exit(2)
75
+
76
+ b2 = backend_abbrev(backend_name)
77
+ clean_slug = slug_clean(slug_arg) if slug_arg else "task"
78
+
79
+ mmdd = datetime.date.today().strftime("%m%d")
80
+
81
+ conn = get_db()
82
+ _n, seq_code = alloc_seq(conn)
83
+ conn.close()
84
+
85
+ run_id = f"{mmdd}-{b2}-{seq_code}-{clean_slug}"
86
+ prompt_path = os.path.join(TASKS_DIR, f"{run_id}.prompt.md")
87
+
88
+ print(prompt_path)
@@ -6,7 +6,10 @@ import os
6
6
  import sys
7
7
  import datetime
8
8
 
9
- from ..core import get_db, create_run, task_paths, UUID_RE
9
+ from ..core import (
10
+ get_db, create_run, task_paths, UUID_RE,
11
+ TASKS_DIR, parse_new_run_id, backend_abbrev,
12
+ )
10
13
  from ..backend import (
11
14
  set_backend_env,
12
15
  build_args,
@@ -19,6 +22,21 @@ from ..stream import execute_run
19
22
  from ..config import Config
20
23
 
21
24
 
25
+ def _is_adopted_path(input_src: str) -> bool:
26
+ """Return True if input_src is a .prompt.md file inside TASKS_DIR with new run_id format."""
27
+ if not input_src or input_src == "-":
28
+ return False
29
+ abs_src = os.path.abspath(input_src)
30
+ tasks_dir = os.path.abspath(TASKS_DIR)
31
+ if not abs_src.startswith(tasks_dir + os.sep):
32
+ return False
33
+ basename = os.path.basename(abs_src)
34
+ if not basename.endswith(".prompt.md"):
35
+ return False
36
+ stem = basename[: -len(".prompt.md")]
37
+ return parse_new_run_id(stem) is not None
38
+
39
+
22
40
  def cmd_run(argv: list[str], config: Config):
23
41
  """handoff run [--backend <name>] [--cwd <dir>] [--pro] (<input-file|-> | --text <prompt...>)."""
24
42
  pro = False
@@ -95,6 +113,9 @@ def cmd_run(argv: list[str], config: Config):
95
113
  print(f"handoff run: cwd not found: {cwd}", file=sys.stderr)
96
114
  sys.exit(2)
97
115
 
116
+ # Determine prompt source and whether to adopt a pre-allocated run_id.
117
+ adopted_run_id: str | None = None
118
+
98
119
  if text_mode:
99
120
  if not text_parts:
100
121
  print("handoff run: --text requires a value", file=sys.stderr)
@@ -103,21 +124,48 @@ def cmd_run(argv: list[str], config: Config):
103
124
  if not prompt_text:
104
125
  print("handoff run: --text requires a non-empty value", file=sys.stderr)
105
126
  sys.exit(2)
127
+ slug = "from-text"
106
128
  elif input_src == "-" or (not input_src and not sys.stdin.isatty()):
107
129
  prompt_text = sys.stdin.read()
130
+ slug = "from-stdin"
108
131
  elif input_src:
109
132
  if not os.path.isfile(input_src):
110
133
  print(f"handoff run: input file not found: {input_src}", file=sys.stderr)
111
134
  sys.exit(2)
112
- with open(input_src) as f:
113
- prompt_text = f.read()
135
+
136
+ if _is_adopted_path(input_src):
137
+ # Adopt: file is already at the canonical tasks/ path with new format.
138
+ stem = os.path.basename(input_src)[: -len(".prompt.md")]
139
+ parsed = parse_new_run_id(stem) # guaranteed non-None by _is_adopted_path
140
+ _mmdd, file_b2, _seq_code, _slug = parsed # type: ignore[misc]
141
+
142
+ # Validate backend2 consistency.
143
+ backend_name_candidate = backend_arg or config.default_backend
144
+ expected_b2 = backend_abbrev(backend_name_candidate)
145
+ if file_b2 != expected_b2:
146
+ print(
147
+ f"handoff run: adopted file has backend '{file_b2}' but "
148
+ f"--backend resolves to '{backend_name_candidate}' (abbrev '{expected_b2}'). "
149
+ f"Use --backend matching the file's backend.",
150
+ file=sys.stderr,
151
+ )
152
+ sys.exit(2)
153
+
154
+ with open(input_src) as f:
155
+ prompt_text = f.read()
156
+ adopted_run_id = stem
157
+ slug = _slug # unused when adopting, but kept for clarity
158
+ else:
159
+ with open(input_src) as f:
160
+ prompt_text = f.read()
161
+ slug = "from-file"
114
162
  else:
115
163
  print("handoff run: input file required, or use --text <prompt...> / pipe via '-'", file=sys.stderr)
116
164
  sys.exit(2)
117
165
 
118
166
  backend_name = backend_arg or config.default_backend
119
167
 
120
- _execute(cwd, prompt_text, backend_name, pro, config)
168
+ _execute(cwd, prompt_text, backend_name, pro, config, slug=slug, adopted_run_id=adopted_run_id)
121
169
 
122
170
 
123
171
  def _execute(
@@ -127,6 +175,8 @@ def _execute(
127
175
  pro: bool,
128
176
  config: Config,
129
177
  resume_session_id: str | None = None,
178
+ slug: str = "task",
179
+ adopted_run_id: str | None = None,
130
180
  ):
131
181
  """Shared execution path for file, stdin, and --text run modes.
132
182
 
@@ -134,6 +184,10 @@ def _execute(
134
184
  claude conversation (`claude -p ... --resume <id>`) rather than starting a
135
185
  fresh session; the new row still gets its own run_id/seq/files but shares the
136
186
  session_id. Used by `handoff resume <seq> <prompt>`.
187
+
188
+ When `adopted_run_id` is given, the pre-allocated run_id from `handoff new`
189
+ is adopted: the seq counter is NOT re-incremented, and the prompt file is
190
+ already at the canonical tasks/ path (not written again).
137
191
  """
138
192
  backend_cfg = config.get_backend(backend_name)
139
193
  if not backend_cfg:
@@ -147,16 +201,36 @@ def _execute(
147
201
  ensure_backend_token_ready(backend_name, backend_cfg, config.user_config_path)
148
202
 
149
203
  conn = get_db()
204
+
205
+ # Check for duplicate dispatch when adopting a pre-allocated run_id.
206
+ if adopted_run_id:
207
+ existing = conn.execute(
208
+ "SELECT run_id FROM runs WHERE run_id = ?", (adopted_run_id,)
209
+ ).fetchone()
210
+ if existing:
211
+ conn.close()
212
+ print(
213
+ f"handoff run: run_id '{adopted_run_id}' already exists in DB — "
214
+ f"duplicate dispatch rejected.",
215
+ file=sys.stderr,
216
+ )
217
+ sys.exit(2)
218
+
150
219
  run_id, uid, jsonl_path = create_run(
151
- conn, cwd, prompt_text, backend_name, session_id=resume_session_id
220
+ conn, cwd, prompt_text, backend_name,
221
+ session_id=resume_session_id,
222
+ slug=slug,
223
+ run_id_override=adopted_run_id,
152
224
  )
153
225
  conn.commit()
154
226
 
155
227
  # tasks dir files
156
228
  prompt_path, out_path, result_path = task_paths(run_id)
157
229
 
158
- with open(prompt_path, "w") as pf:
159
- pf.write(prompt_text)
230
+ # Write prompt file only when not adopting (adopted file is already in place).
231
+ if not adopted_run_id:
232
+ with open(prompt_path, "w") as pf:
233
+ pf.write(prompt_text)
160
234
 
161
235
  # Resolve model
162
236
  model = resolve_backend_model(backend_cfg, pro)
@@ -26,6 +26,50 @@ UUID_RE = re.compile(
26
26
  r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
27
27
  )
28
28
 
29
+ # New run_id format: <mmdd>-<backend2>-<SEQ_CODE>-<slug>
30
+ # e.g. 0611-ds-03-fix-auth
31
+ _NEW_RUN_ID_RE = re.compile(
32
+ r"^(\d{4})-([a-z]{2})-([0-9A-Z]{2})-(.+)$"
33
+ )
34
+
35
+ # Explicit backend abbreviation mapping; others fall back to first 2 chars of name
36
+ _BACKEND_ABBREV: dict[str, str] = {
37
+ "deepseek": "ds",
38
+ "codex": "cx",
39
+ }
40
+
41
+
42
+ def backend_abbrev(backend_name: str) -> str:
43
+ """Return 2-char abbreviation for a backend name."""
44
+ name = backend_name.lower()
45
+ return _BACKEND_ABBREV.get(name, name[:2])
46
+
47
+
48
+ def slug_clean(raw: str) -> str:
49
+ """Sanitise a user-supplied slug: lowercase, only [a-z0-9-], max 3 dash-separated words.
50
+
51
+ Returns 'task' if the input is empty after cleaning.
52
+ """
53
+ s = raw.lower()
54
+ s = re.sub(r"[^a-z0-9-]", "-", s)
55
+ s = re.sub(r"-+", "-", s).strip("-")
56
+ # Keep at most 3 words (segments separated by '-')
57
+ parts = s.split("-")
58
+ parts = [p for p in parts if p]
59
+ s = "-".join(parts[:3])
60
+ return s or "task"
61
+
62
+
63
+ def parse_new_run_id(stem: str) -> Optional[tuple[str, str, str, str]]:
64
+ """Parse a new-format run_id stem.
65
+
66
+ Returns (mmdd, backend2, seq_code, slug) or None if it doesn't match.
67
+ """
68
+ m = _NEW_RUN_ID_RE.match(stem)
69
+ if not m:
70
+ return None
71
+ return m.group(1), m.group(2), m.group(3), m.group(4)
72
+
29
73
 
30
74
  # ── migration ──────────────────────────────────────────────────────────────────
31
75
 
@@ -175,6 +219,8 @@ def create_run(
175
219
  prompt_text: str,
176
220
  backend_name: str = "",
177
221
  session_id: Optional[str] = None,
222
+ slug: str = "task",
223
+ run_id_override: Optional[str] = None,
178
224
  ):
179
225
  """Allocate a new run inside a BEGIN IMMEDIATE transaction.
180
226
 
@@ -186,6 +232,12 @@ def create_run(
186
232
  `resume` continuation it is the parent conversation's session_id, so the new
187
233
  row (new run_id/seq/files) shares one claude session across turns.
188
234
 
235
+ `slug` is appended to the run_id (≤3 dash-separated words, cleaned by slug_clean).
236
+
237
+ `run_id_override` is used when adopting a pre-allocated run_id from `handoff new`.
238
+ When set, the seq counter is NOT incremented — the counter was already bumped by
239
+ `new`. The seq and seq_code are extracted from the override run_id.
240
+
189
241
  Returns (run_id, uuid, jsonl_path). Caller must commit/rollback.
190
242
  """
191
243
  conn.execute("BEGIN IMMEDIATE")
@@ -193,23 +245,42 @@ def create_run(
193
245
  today_iso = today.isoformat()
194
246
  mmdd = today.strftime("%m%d")
195
247
 
196
- row = conn.execute(
197
- "SELECT last_n FROM run_counters WHERE day = ?", (today_iso,)
198
- ).fetchone()
199
- n = (row[0] + 1) if row else 1
248
+ if run_id_override:
249
+ # Adopt a pre-allocated run_id. Parse seq_code from it to fill seq.
250
+ parsed = parse_new_run_id(run_id_override)
251
+ if parsed is None:
252
+ conn.execute("ROLLBACK")
253
+ print(f"handoff: cannot parse adopted run_id '{run_id_override}'", file=sys.stderr)
254
+ sys.exit(2)
255
+ _mmdd, _b2, seq_code, _slug = parsed
256
+ try:
257
+ n = seq_code_to_counter(seq_code)
258
+ except ValueError:
259
+ conn.execute("ROLLBACK")
260
+ print(f"handoff: invalid seq_code in run_id '{run_id_override}'", file=sys.stderr)
261
+ sys.exit(2)
262
+ run_id = run_id_override
263
+ else:
264
+ row = conn.execute(
265
+ "SELECT last_n FROM run_counters WHERE day = ?", (today_iso,)
266
+ ).fetchone()
267
+ n = (row[0] + 1) if row else 1
200
268
 
201
- if n > _MAX_DAILY:
202
- conn.execute("ROLLBACK")
203
- print("handoff: exceeded maximum daily run count (ZZ = 1035)", file=sys.stderr)
204
- sys.exit(2)
269
+ if n > _MAX_DAILY:
270
+ conn.execute("ROLLBACK")
271
+ print("handoff: exceeded maximum daily run count (ZZ = 1035)", file=sys.stderr)
272
+ sys.exit(2)
205
273
 
206
- conn.execute(
207
- "INSERT OR REPLACE INTO run_counters (day, last_n) VALUES (?, ?)",
208
- (today_iso, n),
209
- )
274
+ conn.execute(
275
+ "INSERT OR REPLACE INTO run_counters (day, last_n) VALUES (?, ?)",
276
+ (today_iso, n),
277
+ )
278
+
279
+ seq_code = counter_to_seq_code(n)
280
+ b2 = backend_abbrev(backend_name) if backend_name else "xx"
281
+ clean = slug_clean(slug)
282
+ run_id = f"{mmdd}-{b2}-{seq_code}-{clean}"
210
283
 
211
- seq_code = counter_to_seq_code(n)
212
- run_id = f"hd-{mmdd}-{seq_code}"
213
284
  uid = str(_uuid.uuid4()).lower()
214
285
  sess = session_id or uid
215
286
  jsonl_path = os.path.join(DB_DIR, f"{run_id}-{uid}.jsonl")
@@ -296,7 +367,31 @@ def task_paths(run_id: str):
296
367
  """Return (prompt, out, result) paths under TASKS_DIR using run_id as basename."""
297
368
  os.makedirs(TASKS_DIR, exist_ok=True)
298
369
  return (
299
- os.path.join(TASKS_DIR, f"{run_id}.prompt.txt"),
370
+ os.path.join(TASKS_DIR, f"{run_id}.prompt.md"),
300
371
  os.path.join(TASKS_DIR, f"{run_id}.out.txt"),
301
372
  os.path.join(TASKS_DIR, f"{run_id}.result.md"),
302
373
  )
374
+
375
+
376
+ def alloc_seq(conn: sqlite3.Connection) -> tuple[int, str]:
377
+ """Atomically increment today's run counter and return (n, seq_code).
378
+
379
+ Used by `handoff new` to pre-allocate a seq without creating a run row.
380
+ Caller must hold (or acquire) a transaction.
381
+ """
382
+ today = datetime.date.today().isoformat()
383
+ conn.execute("BEGIN IMMEDIATE")
384
+ row = conn.execute(
385
+ "SELECT last_n FROM run_counters WHERE day = ?", (today,)
386
+ ).fetchone()
387
+ n = (row[0] + 1) if row else 1
388
+ if n > _MAX_DAILY:
389
+ conn.execute("ROLLBACK")
390
+ print("handoff: exceeded maximum daily run count (ZZ = 1035)", file=sys.stderr)
391
+ sys.exit(2)
392
+ conn.execute(
393
+ "INSERT OR REPLACE INTO run_counters (day, last_n) VALUES (?, ?)",
394
+ (today, n),
395
+ )
396
+ conn.commit()
397
+ return n, counter_to_seq_code(n)