handoff-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +781 -0
- package/Cargo.toml +24 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/handoff-mcp.js +22 -0
- package/package.json +43 -0
- package/scripts/postinstall.js +44 -0
- package/src/lib.rs +2 -0
- package/src/main.rs +29 -0
- package/src/mcp/handlers/config.rs +115 -0
- package/src/mcp/handlers/dashboard.rs +109 -0
- package/src/mcp/handlers/init.rs +28 -0
- package/src/mcp/handlers/list_tasks.rs +61 -0
- package/src/mcp/handlers/load_context.rs +94 -0
- package/src/mcp/handlers/mod.rs +58 -0
- package/src/mcp/handlers/save_context.rs +96 -0
- package/src/mcp/handlers/update_task.rs +207 -0
- package/src/mcp/mod.rs +6 -0
- package/src/mcp/protocol.rs +41 -0
- package/src/mcp/resources.rs +55 -0
- package/src/mcp/router.rs +150 -0
- package/src/mcp/tools.rs +284 -0
- package/src/mcp/types.rs +108 -0
- package/src/storage/config.rs +112 -0
- package/src/storage/git.rs +47 -0
- package/src/storage/mod.rs +41 -0
- package/src/storage/sessions.rs +167 -0
- package/src/storage/tasks.rs +365 -0
- package/tests/mcp_protocol.rs +204 -0
- package/tests/storage_config.rs +107 -0
- package/tests/storage_sessions.rs +195 -0
- package/tests/storage_tasks.rs +302 -0
- package/tests/tool_dashboard.rs +165 -0
- package/tests/tool_init.rs +176 -0
- package/tests/tool_sessions.rs +318 -0
- package/tests/tool_tasks.rs +408 -0
package/Cargo.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "handoff-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "MCP server that gives AI coding agents persistent memory across sessions"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
repository = "https://github.com/alphaelements/handoff-mcp"
|
|
8
|
+
homepage = "https://github.com/alphaelements/handoff-mcp"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
keywords = ["mcp", "ai", "claude", "handoff", "context"]
|
|
11
|
+
categories = ["command-line-utilities", "development-tools"]
|
|
12
|
+
rust-version = "1.70"
|
|
13
|
+
exclude = ["lefthook.yml", "tmp/", "wiki/", "docs/", ".claude/", ".vscode/"]
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
serde = { version = "1", features = ["derive"] }
|
|
17
|
+
serde_json = "1"
|
|
18
|
+
toml = "0.8"
|
|
19
|
+
chrono = { version = "0.4", features = ["serde"] }
|
|
20
|
+
anyhow = "1"
|
|
21
|
+
thiserror = "2"
|
|
22
|
+
|
|
23
|
+
[dev-dependencies]
|
|
24
|
+
tempfile = "3.27.0"
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AlphaElements Co., Ltd.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# handoff-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that gives AI coding agents persistent memory across sessions.
|
|
4
|
+
|
|
5
|
+
When you close a Claude Code session and start a new one, the new session has no idea what the previous one was doing. handoff-mcp solves this by saving session context — tasks, decisions, blockers, and file pointers — to a local `.handoff/` directory that the next session can load automatically.
|
|
6
|
+
|
|
7
|
+
## The Problem
|
|
8
|
+
|
|
9
|
+
AI coding sessions are stateless. Every new session starts from zero:
|
|
10
|
+
|
|
11
|
+
- **"What was I working on?"** — the agent doesn't know
|
|
12
|
+
- **"What decisions were made?"** — lost with the previous context window
|
|
13
|
+
- **"What's left to do?"** — you have to re-explain everything
|
|
14
|
+
|
|
15
|
+
This gets painful fast on multi-session projects.
|
|
16
|
+
|
|
17
|
+
## How It Works
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Session 1 Session 2
|
|
21
|
+
┌──────────────┐ ┌──────────────┐
|
|
22
|
+
│ Working... │ .handoff/ │ Loading... │
|
|
23
|
+
│ │──────────────────>│ │
|
|
24
|
+
│ save_context │ tasks/ │ load_context │
|
|
25
|
+
│ - summary │ sessions/ │ - tasks │
|
|
26
|
+
│ - decisions │ config.toml │ - decisions │
|
|
27
|
+
│ - blockers │ │ - blockers │
|
|
28
|
+
│ - tasks │ │ - git state │
|
|
29
|
+
└──────────────┘ └──────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
At session end, the agent calls `handoff_save_context` to persist what matters. At session start, it calls `handoff_load_context` to pick up where things left off.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
### npm (recommended)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g handoff-mcp-server
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Build from source
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/alphaelements/handoff-mcp.git
|
|
46
|
+
cd handoff-mcp
|
|
47
|
+
cargo build --release
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Setup
|
|
51
|
+
|
|
52
|
+
Add to your Claude Code MCP configuration:
|
|
53
|
+
|
|
54
|
+
**Global** (`~/.claude/.mcp.json`) — available in all projects:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"handoff": {
|
|
59
|
+
"command": "handoff-mcp",
|
|
60
|
+
"args": []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Per-project** (`.mcp.json` in project root):
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"handoff": {
|
|
70
|
+
"command": "handoff-mcp",
|
|
71
|
+
"args": []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
1. **Initialize** a project:
|
|
79
|
+
|
|
80
|
+
The agent calls `handoff_init` with your project name. This creates a `.handoff/` directory:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
.handoff/
|
|
84
|
+
├── config.toml # Project settings
|
|
85
|
+
├── sessions/ # Session history (TOML files)
|
|
86
|
+
└── tasks/ # Task tree (directories + TOML files)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
2. **Work normally** — create tasks, track progress, make decisions.
|
|
90
|
+
|
|
91
|
+
3. **Save context** at session end — the agent captures a summary, decisions, blockers, and references.
|
|
92
|
+
|
|
93
|
+
4. **Load context** at next session start — the agent reads back everything and resumes.
|
|
94
|
+
|
|
95
|
+
> Add `.handoff/` to your `.gitignore` — it contains local working state, not code.
|
|
96
|
+
|
|
97
|
+
## Tools
|
|
98
|
+
|
|
99
|
+
| Tool | Purpose |
|
|
100
|
+
|------|---------|
|
|
101
|
+
| `handoff_init` | Initialize `.handoff/` directory for a project |
|
|
102
|
+
| `handoff_load_context` | Load session context, tasks, and git state at session start |
|
|
103
|
+
| `handoff_save_context` | Save session summary, decisions, blockers, and references |
|
|
104
|
+
| `handoff_list_tasks` | List tasks with optional status filter |
|
|
105
|
+
| `handoff_update_task` | Create, update, or move tasks in a hierarchical tree |
|
|
106
|
+
| `handoff_get_config` | Read project configuration |
|
|
107
|
+
| `handoff_update_config` | Update project configuration |
|
|
108
|
+
| `handoff_dashboard` | Overview of all handoff-enabled projects |
|
|
109
|
+
|
|
110
|
+
### Task Management
|
|
111
|
+
|
|
112
|
+
Tasks are stored as a directory tree, supporting hierarchical structures:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
tasks/
|
|
116
|
+
├── 01-todo--implement-auth/
|
|
117
|
+
│ ├── task.toml
|
|
118
|
+
│ ├── 01.1-done--design-schema/
|
|
119
|
+
│ │ └── task.toml
|
|
120
|
+
│ └── 01.2-in_progress--write-handlers/
|
|
121
|
+
│ └── task.toml
|
|
122
|
+
└── 02-blocked--deploy-staging/
|
|
123
|
+
└── task.toml
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Statuses: `todo` | `in_progress` | `review` | `done` | `blocked` | `skipped`
|
|
127
|
+
|
|
128
|
+
Each task can have:
|
|
129
|
+
- Priority (`low` / `medium` / `high`)
|
|
130
|
+
- Labels
|
|
131
|
+
- Done criteria (checklist items)
|
|
132
|
+
- Links to issues, MRs, or docs
|
|
133
|
+
- Notes
|
|
134
|
+
|
|
135
|
+
### Session Context
|
|
136
|
+
|
|
137
|
+
When saving context, the agent can record:
|
|
138
|
+
|
|
139
|
+
- **Summary** — one-line description of what happened
|
|
140
|
+
- **Decisions** — what was decided and why, with confidence levels (`confirmed` / `estimated` / `unverified`)
|
|
141
|
+
- **Blockers** — what's preventing progress
|
|
142
|
+
- **Checklist** — items for the next session
|
|
143
|
+
- **Handoff notes** — categorized as `caution`, `context`, or `suggestion`
|
|
144
|
+
- **References** — links to files, issues, MRs, wiki pages, or URLs
|
|
145
|
+
- **Context pointers** — specific files and line ranges the next session should look at
|
|
146
|
+
- **Git state** — current branch, recent commits, and dirty files (captured automatically)
|
|
147
|
+
|
|
148
|
+
### Dashboard
|
|
149
|
+
|
|
150
|
+
`handoff_dashboard` scans directories for projects with `.handoff/` and shows a summary:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
## my-project (3 tasks)
|
|
154
|
+
- [in_progress] Implement auth (high)
|
|
155
|
+
- [todo] Add tests (medium)
|
|
156
|
+
- [blocked] Deploy staging (medium)
|
|
157
|
+
|
|
158
|
+
## other-project (1 task)
|
|
159
|
+
- [review] Update README (low)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Configuration
|
|
163
|
+
|
|
164
|
+
`.handoff/config.toml`:
|
|
165
|
+
|
|
166
|
+
```toml
|
|
167
|
+
[project]
|
|
168
|
+
name = "my-project"
|
|
169
|
+
description = "Project description"
|
|
170
|
+
|
|
171
|
+
[settings]
|
|
172
|
+
history_limit = 20 # Max closed sessions to keep
|
|
173
|
+
done_task_limit = 10 # Max completed tasks to show
|
|
174
|
+
auto_git_summary = true # Capture git state automatically
|
|
175
|
+
|
|
176
|
+
[dashboard]
|
|
177
|
+
scan_dirs = ["~/pro/"] # Directories to scan for dashboard
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## MCP Resources
|
|
181
|
+
|
|
182
|
+
| URI | Description |
|
|
183
|
+
|-----|-------------|
|
|
184
|
+
| `handoff://sessions` | Active session data (JSON) |
|
|
185
|
+
| `handoff://config` | Project configuration (TOML) |
|
|
186
|
+
|
|
187
|
+
## Recommended CLAUDE.md Setup
|
|
188
|
+
|
|
189
|
+
Add the following to your project's `CLAUDE.md` so the agent uses handoff consistently:
|
|
190
|
+
|
|
191
|
+
```markdown
|
|
192
|
+
## Session Handoff
|
|
193
|
+
|
|
194
|
+
This project uses handoff-mcp for session continuity.
|
|
195
|
+
|
|
196
|
+
- **Session start**: Call `handoff_load_context` to load previous session state.
|
|
197
|
+
If not initialized, call `handoff_init` with the project name.
|
|
198
|
+
- **Session end**: Call `handoff_save_context` with a summary, decisions, and blockers.
|
|
199
|
+
- **During work**: Use `handoff_update_task` to track progress.
|
|
200
|
+
Mark tasks `in_progress` when starting, `done` when complete.
|
|
201
|
+
- **Decisions**: Record decisions with confidence levels as they are made,
|
|
202
|
+
not just at session end. Use `confirmed` for verified facts, `estimated`
|
|
203
|
+
for reasonable assumptions, `unverified` for unknowns.
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Skill File (Optional)
|
|
207
|
+
|
|
208
|
+
This repository includes a skill file at [`skills/handoff/SKILL.md`](skills/handoff/SKILL.md) that makes handoff behavior automatic in Claude Code. Copy it to your user skills directory:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
cp -r skills/handoff ~/.claude/skills/
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
This teaches the agent to automatically load context at session start, track tasks during work, and save context at session end.
|
|
215
|
+
|
|
216
|
+
## Compatibility
|
|
217
|
+
|
|
218
|
+
- **Claude Code** — fully supported (stdio transport)
|
|
219
|
+
- **Other MCP clients** — any client supporting the MCP stdio transport
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { execFileSync } = require("child_process");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
|
|
8
|
+
const binary = path.join(__dirname, "handoff-mcp-bin");
|
|
9
|
+
|
|
10
|
+
if (!fs.existsSync(binary)) {
|
|
11
|
+
console.error(
|
|
12
|
+
"handoff-mcp binary not found. Try reinstalling: npm install -g handoff-mcp-server"
|
|
13
|
+
);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
execFileSync(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e.status != null) process.exit(e.status);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "handoff-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that gives AI coding agents persistent memory across sessions",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/alphaelements/handoff-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/alphaelements/handoff-mcp",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"ai",
|
|
15
|
+
"claude",
|
|
16
|
+
"handoff",
|
|
17
|
+
"context",
|
|
18
|
+
"session"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"handoff-mcp": "./bin/handoff-mcp.js"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"postinstall": "node scripts/postinstall.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin/",
|
|
28
|
+
"scripts/",
|
|
29
|
+
"src/",
|
|
30
|
+
"tests/",
|
|
31
|
+
"Cargo.toml",
|
|
32
|
+
"Cargo.lock",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"os": [
|
|
37
|
+
"linux",
|
|
38
|
+
"darwin"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=16"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { execSync } = require("child_process");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
9
|
+
const BIN_DIR = path.join(ROOT, "bin");
|
|
10
|
+
const BINARY = path.join(BIN_DIR, "handoff-mcp-bin");
|
|
11
|
+
|
|
12
|
+
if (fs.existsSync(BINARY)) {
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
execSync("cargo --version", { stdio: "ignore" });
|
|
18
|
+
} catch {
|
|
19
|
+
console.error(
|
|
20
|
+
"Error: Rust toolchain not found.\n" +
|
|
21
|
+
"handoff-mcp-server requires Rust to build from source.\n" +
|
|
22
|
+
"Install Rust: https://rustup.rs/"
|
|
23
|
+
);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log("Building handoff-mcp from source...");
|
|
28
|
+
try {
|
|
29
|
+
execSync("cargo build --release", { cwd: ROOT, stdio: "inherit" });
|
|
30
|
+
} catch {
|
|
31
|
+
console.error("Error: cargo build failed.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const built = path.join(ROOT, "target", "release", "handoff-mcp");
|
|
36
|
+
if (!fs.existsSync(built)) {
|
|
37
|
+
console.error("Error: binary not found after build.");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
42
|
+
fs.copyFileSync(built, BINARY);
|
|
43
|
+
fs.chmodSync(BINARY, 0o755);
|
|
44
|
+
console.log("handoff-mcp installed successfully.");
|
package/src/lib.rs
ADDED
package/src/main.rs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
use std::io::{self, BufRead, Write};
|
|
2
|
+
|
|
3
|
+
use handoff_mcp::mcp::protocol::process_line;
|
|
4
|
+
|
|
5
|
+
fn main() {
|
|
6
|
+
eprintln!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
|
|
7
|
+
|
|
8
|
+
let stdin = io::stdin();
|
|
9
|
+
let mut stdout = io::stdout();
|
|
10
|
+
|
|
11
|
+
for line in stdin.lock().lines() {
|
|
12
|
+
let line = match line {
|
|
13
|
+
Ok(l) => l,
|
|
14
|
+
Err(e) => {
|
|
15
|
+
eprintln!("stdin read error: {e}");
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if let Some(response) = process_line(&line) {
|
|
21
|
+
if writeln!(stdout, "{response}").is_err() {
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
if stdout.flush().is_err() {
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::config::{read_config, write_config};
|
|
6
|
+
use crate::storage::ensure_handoff_exists;
|
|
7
|
+
|
|
8
|
+
pub fn handle_get(arguments: &Value) -> Result<String> {
|
|
9
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
10
|
+
|
|
11
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
12
|
+
let config = read_config(&handoff.join("config.toml"))?;
|
|
13
|
+
let result = serde_json::to_value(&config).context("Failed to serialize config")?;
|
|
14
|
+
serde_json::to_string_pretty(&result).context("Failed to format config")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
18
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
19
|
+
|
|
20
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
21
|
+
let config_path = handoff.join("config.toml");
|
|
22
|
+
let mut config = read_config(&config_path)?;
|
|
23
|
+
|
|
24
|
+
let updates = arguments
|
|
25
|
+
.get("updates")
|
|
26
|
+
.ok_or_else(|| anyhow::anyhow!("'updates' parameter is required"))?;
|
|
27
|
+
|
|
28
|
+
let updates = updates
|
|
29
|
+
.as_object()
|
|
30
|
+
.ok_or_else(|| anyhow::anyhow!("'updates' must be an object"))?;
|
|
31
|
+
|
|
32
|
+
let mut applied = Vec::new();
|
|
33
|
+
|
|
34
|
+
for (key, value) in updates {
|
|
35
|
+
match key.as_str() {
|
|
36
|
+
"settings.history_limit" => {
|
|
37
|
+
if let Some(n) = value.as_u64() {
|
|
38
|
+
config.settings.history_limit = n as u32;
|
|
39
|
+
applied.push(format!("settings.history_limit = {n}"));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
"settings.done_task_limit" => {
|
|
43
|
+
if let Some(n) = value.as_u64() {
|
|
44
|
+
config.settings.done_task_limit = n as u32;
|
|
45
|
+
applied.push(format!("settings.done_task_limit = {n}"));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
"settings.auto_git_summary" => {
|
|
49
|
+
if let Some(b) = value.as_bool() {
|
|
50
|
+
config.settings.auto_git_summary = b;
|
|
51
|
+
applied.push(format!("settings.auto_git_summary = {b}"));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
"settings.context_files" => {
|
|
55
|
+
if let Some(arr) = value.as_array() {
|
|
56
|
+
config.settings.context_files = arr
|
|
57
|
+
.iter()
|
|
58
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
59
|
+
.collect();
|
|
60
|
+
applied.push(format!(
|
|
61
|
+
"settings.context_files = {:?}",
|
|
62
|
+
config.settings.context_files
|
|
63
|
+
));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
"dashboard.scan_dirs" => {
|
|
67
|
+
if let Some(arr) = value.as_array() {
|
|
68
|
+
config.dashboard.scan_dirs = arr
|
|
69
|
+
.iter()
|
|
70
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
71
|
+
.collect();
|
|
72
|
+
applied.push(format!(
|
|
73
|
+
"dashboard.scan_dirs = {:?}",
|
|
74
|
+
config.dashboard.scan_dirs
|
|
75
|
+
));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
"dashboard.exclude_patterns" => {
|
|
79
|
+
if let Some(arr) = value.as_array() {
|
|
80
|
+
config.dashboard.exclude_patterns = arr
|
|
81
|
+
.iter()
|
|
82
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
83
|
+
.collect();
|
|
84
|
+
applied.push(format!(
|
|
85
|
+
"dashboard.exclude_patterns = {:?}",
|
|
86
|
+
config.dashboard.exclude_patterns
|
|
87
|
+
));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
"project.name" => {
|
|
91
|
+
if let Some(s) = value.as_str() {
|
|
92
|
+
config.project.name = s.to_string();
|
|
93
|
+
applied.push(format!("project.name = {s}"));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
"project.description" => {
|
|
97
|
+
if let Some(s) = value.as_str() {
|
|
98
|
+
config.project.description = Some(s.to_string());
|
|
99
|
+
applied.push(format!("project.description = {s}"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
other => {
|
|
103
|
+
applied.push(format!("{other}: unknown key (skipped)"));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
write_config(&config_path, &config)?;
|
|
109
|
+
|
|
110
|
+
if applied.is_empty() {
|
|
111
|
+
Ok("No updates applied".to_string())
|
|
112
|
+
} else {
|
|
113
|
+
Ok(format!("Updated config:\n{}", applied.join("\n")))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result};
|
|
4
|
+
use serde_json::Value;
|
|
5
|
+
|
|
6
|
+
use crate::storage::config::read_config;
|
|
7
|
+
use crate::storage::sessions::read_active_sessions;
|
|
8
|
+
use crate::storage::tasks::build_task_index;
|
|
9
|
+
|
|
10
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
11
|
+
let scan_dirs: Vec<String> = arguments
|
|
12
|
+
.get("scan_dirs")
|
|
13
|
+
.and_then(|v| v.as_array())
|
|
14
|
+
.map(|arr| {
|
|
15
|
+
arr.iter()
|
|
16
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
17
|
+
.collect()
|
|
18
|
+
})
|
|
19
|
+
.unwrap_or_else(|| vec!["~/pro/".to_string()]);
|
|
20
|
+
|
|
21
|
+
let mut projects = Vec::new();
|
|
22
|
+
let mut total_active = 0u32;
|
|
23
|
+
let mut total_blocked = 0u32;
|
|
24
|
+
|
|
25
|
+
for scan_dir in &scan_dirs {
|
|
26
|
+
let expanded = expand_tilde(scan_dir);
|
|
27
|
+
let expanded_path = Path::new(&expanded);
|
|
28
|
+
|
|
29
|
+
if !expanded_path.exists() {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let entries = match std::fs::read_dir(expanded_path) {
|
|
34
|
+
Ok(e) => e,
|
|
35
|
+
Err(_) => continue,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for entry in entries.flatten() {
|
|
39
|
+
if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let project_path = entry.path();
|
|
44
|
+
let handoff_dir = project_path.join(".handoff");
|
|
45
|
+
|
|
46
|
+
if !handoff_dir.join("config.toml").exists() {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if let Ok(info) = collect_project_info(&project_path) {
|
|
51
|
+
total_active += info["active_tasks"].as_u64().unwrap_or(0) as u32;
|
|
52
|
+
total_blocked += info["blocked_tasks"].as_u64().unwrap_or(0) as u32;
|
|
53
|
+
projects.push(info);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let result = serde_json::json!({
|
|
59
|
+
"projects": projects,
|
|
60
|
+
"total_active_tasks": total_active,
|
|
61
|
+
"total_blocked": total_blocked,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
serde_json::to_string_pretty(&result).context("Failed to serialize dashboard")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fn collect_project_info(project_path: &Path) -> Result<Value> {
|
|
68
|
+
let handoff_dir = project_path.join(".handoff");
|
|
69
|
+
let config = read_config(&handoff_dir.join("config.toml"))?;
|
|
70
|
+
|
|
71
|
+
let sessions = read_active_sessions(&handoff_dir.join("sessions"))?;
|
|
72
|
+
|
|
73
|
+
let (_, summary) =
|
|
74
|
+
build_task_index(&handoff_dir.join("tasks"), config.settings.done_task_limit)?;
|
|
75
|
+
|
|
76
|
+
let last_session_ended = sessions.last().and_then(|s| s.ended_at.clone());
|
|
77
|
+
|
|
78
|
+
let branch = sessions.last().and_then(|s| s.branch.clone());
|
|
79
|
+
|
|
80
|
+
let active_tasks = *summary.by_status.get("in_progress").unwrap_or(&0)
|
|
81
|
+
+ *summary.by_status.get("todo").unwrap_or(&0)
|
|
82
|
+
+ *summary.by_status.get("review").unwrap_or(&0);
|
|
83
|
+
|
|
84
|
+
let blocked_tasks = *summary.by_status.get("blocked").unwrap_or(&0);
|
|
85
|
+
|
|
86
|
+
let blockers: Vec<String> = sessions
|
|
87
|
+
.iter()
|
|
88
|
+
.flat_map(|s| s.blockers.iter().cloned())
|
|
89
|
+
.collect();
|
|
90
|
+
|
|
91
|
+
Ok(serde_json::json!({
|
|
92
|
+
"name": config.project.name,
|
|
93
|
+
"path": project_path.to_string_lossy(),
|
|
94
|
+
"last_session_ended": last_session_ended,
|
|
95
|
+
"branch": branch,
|
|
96
|
+
"active_tasks": active_tasks,
|
|
97
|
+
"blocked_tasks": blocked_tasks,
|
|
98
|
+
"blockers": blockers,
|
|
99
|
+
}))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fn expand_tilde(path: &str) -> String {
|
|
103
|
+
if let Some(rest) = path.strip_prefix("~/") {
|
|
104
|
+
if let Ok(home) = std::env::var("HOME") {
|
|
105
|
+
return format!("{home}/{rest}");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
path.to_string()
|
|
109
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
use anyhow::Result;
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage;
|
|
6
|
+
|
|
7
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
8
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
9
|
+
|
|
10
|
+
let project_name = arguments
|
|
11
|
+
.get("project_name")
|
|
12
|
+
.and_then(|v| v.as_str())
|
|
13
|
+
.ok_or_else(|| anyhow::anyhow!("project_name is required"))?;
|
|
14
|
+
|
|
15
|
+
let description = arguments
|
|
16
|
+
.get("description")
|
|
17
|
+
.and_then(|v| v.as_str())
|
|
18
|
+
.unwrap_or("");
|
|
19
|
+
|
|
20
|
+
storage::init_handoff(&project_dir, project_name, description)?;
|
|
21
|
+
|
|
22
|
+
Ok(format!(
|
|
23
|
+
"Initialized handoff tracking for '{}' at {}/.handoff/\n\
|
|
24
|
+
Created: config.toml, sessions/, tasks/",
|
|
25
|
+
project_name,
|
|
26
|
+
project_dir.display()
|
|
27
|
+
))
|
|
28
|
+
}
|