handoff-mcp-server 0.10.0 → 0.11.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 +2 -1
- package/Cargo.toml +2 -1
- package/README.md +112 -32
- package/package.json +2 -1
- package/skills/handoff/SKILL.md +223 -0
- package/skills/handoff-import/SKILL.md +238 -0
- package/skills/handoff-load/SKILL.md +37 -0
- package/skills/handoff-refer/SKILL.md +71 -0
- package/src/mcp/handlers/assignees.rs +254 -0
- package/src/mcp/handlers/auto_schedule.rs +498 -0
- package/src/mcp/handlers/bulk_update.rs +115 -0
- package/src/mcp/handlers/calendar.rs +196 -0
- package/src/mcp/handlers/capacity.rs +316 -0
- package/src/mcp/handlers/config.rs +212 -67
- package/src/mcp/handlers/config_crud.rs +183 -0
- package/src/mcp/handlers/get_session.rs +48 -0
- package/src/mcp/handlers/get_task.rs +1 -0
- package/src/mcp/handlers/import_context.rs +7 -0
- package/src/mcp/handlers/list_sessions.rs +129 -0
- package/src/mcp/handlers/list_tasks.rs +76 -10
- package/src/mcp/handlers/load_context.rs +16 -0
- package/src/mcp/handlers/log_time.rs +70 -0
- package/src/mcp/handlers/metrics.rs +185 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +29 -0
- package/src/mcp/handlers/update_session.rs +1 -1
- package/src/mcp/handlers/update_task.rs +46 -2
- package/src/mcp/tools.rs +339 -3
- package/src/storage/config.rs +145 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +1 -1
- package/src/storage/sessions.rs +95 -22
- package/src/storage/tasks.rs +67 -2
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: handoff-import
|
|
3
|
+
description: "Import existing handoff documents into .handoff/ management. Reads the specified file, structures its content into tasks/decisions/blockers/notes, and calls handoff_import_context in one shot. Triggers on '/handoff-import <path>', 'import handoff', 'take this into handoff'."
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Handoff Import
|
|
8
|
+
|
|
9
|
+
Import an existing handoff document (Markdown, JSON, or free-text) into
|
|
10
|
+
structured `.handoff/` management via a single `handoff_import_context` call.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
/handoff-import tmp/260601-sprint-handoff.md
|
|
16
|
+
/handoff-import path/to/any-document.md
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If called without arguments, ask the user for a file path.
|
|
20
|
+
|
|
21
|
+
## Procedure
|
|
22
|
+
|
|
23
|
+
1. **Init check**: Verify `.handoff/` exists. If not, run `handoff_init` first.
|
|
24
|
+
|
|
25
|
+
2. **Read the source**: Read the specified file with the Read tool.
|
|
26
|
+
|
|
27
|
+
3. **Analyze and structure**: Decompose the content into the categories below.
|
|
28
|
+
|
|
29
|
+
4. **Import**: Call `handoff_import_context` once with all extracted data.
|
|
30
|
+
|
|
31
|
+
5. **Report**: Show how many tasks were created, whether a session was saved,
|
|
32
|
+
and whether raw_notes captured anything.
|
|
33
|
+
|
|
34
|
+
6. **Verify**: Call `handoff_list_tasks` to display the import result and
|
|
35
|
+
ask the user to confirm correctness.
|
|
36
|
+
|
|
37
|
+
## Field Mapping Guide
|
|
38
|
+
|
|
39
|
+
### tasks — Extracting Structured Tasks
|
|
40
|
+
|
|
41
|
+
Every actionable item in the source document becomes a task.
|
|
42
|
+
Always populate these fields:
|
|
43
|
+
|
|
44
|
+
| Field | How to extract |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `title` | Short imperative phrase (e.g. "Add retry logic to USB reconnect") |
|
|
47
|
+
| `status` | Map from source: "done"/"completed" -> `done`, "WIP"/"in progress" -> `in_progress`, "blocked" -> `blocked`, else `todo` |
|
|
48
|
+
| `priority` | See priority rules below. Must be `low`, `medium`, or `high` |
|
|
49
|
+
| `notes` | Context that doesn't fit elsewhere: root cause, constraints, approach taken |
|
|
50
|
+
| `labels` | Category tags from the source (e.g. `["auth", "security"]`, `["pio", "dma"]`) |
|
|
51
|
+
| `links` | Related file paths, issue URLs, MR URLs, wiki pages |
|
|
52
|
+
| `done_criteria` | Verifiable checklist items extracted from prose (see below) |
|
|
53
|
+
| `children` | Sub-tasks nested under a parent |
|
|
54
|
+
| `schedule.estimate_hours` | If effort estimates are mentioned (e.g. "~2h", "half a day") |
|
|
55
|
+
| `schedule.due_date` | If deadlines are mentioned (ISO format) |
|
|
56
|
+
| `schedule.milestone` | If milestone names are mentioned |
|
|
57
|
+
| `dependencies` | Task IDs this task depends on (e.g. "after t1", "once X is done") |
|
|
58
|
+
| `order` | Display ordering hints (e.g. P0→P1→P2 maps to 0, 1, 2) |
|
|
59
|
+
|
|
60
|
+
### done_criteria — Converting Prose to Checkable Items
|
|
61
|
+
|
|
62
|
+
Transform vague descriptions into specific, testable criteria:
|
|
63
|
+
|
|
64
|
+
**Before** (raw handoff prose):
|
|
65
|
+
> USB reconnect needs to handle the case where the host doesn't re-enumerate.
|
|
66
|
+
> Also make sure the pattern engine restarts cleanly.
|
|
67
|
+
|
|
68
|
+
**After** (structured done_criteria):
|
|
69
|
+
```json
|
|
70
|
+
[
|
|
71
|
+
{"item": "Host non-enumeration triggers fallback reset after 3s timeout", "checked": false},
|
|
72
|
+
{"item": "Pattern engine SM_RESTART executes on reconnect", "checked": false},
|
|
73
|
+
{"item": "cargo test passes for reconnect scenarios", "checked": false}
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each criterion should be:
|
|
78
|
+
- **Observable**: can be verified by running code, reading output, or checking state
|
|
79
|
+
- **Specific**: names the function, file, behavior, or metric
|
|
80
|
+
- **Independent**: can be checked without knowing other criteria
|
|
81
|
+
|
|
82
|
+
**done_criteria must cover the full verification chain** — not just implementation:
|
|
83
|
+
|
|
84
|
+
| Category | Example criteria |
|
|
85
|
+
|---|---|
|
|
86
|
+
| Implementation | "Add validation logic to signup form" |
|
|
87
|
+
| Automated checks | "All tests pass, linter clean" |
|
|
88
|
+
| **Real-run verification** | "App runs and the feature works as expected in the actual environment" |
|
|
89
|
+
|
|
90
|
+
A task is not done until verified end-to-end by running the real artifact.
|
|
91
|
+
Passing automated checks alone is insufficient.
|
|
92
|
+
|
|
93
|
+
### links — What to Reference
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
[
|
|
97
|
+
"src/usb/reconnect.rs",
|
|
98
|
+
"https://gitlab.com/group/project/-/issues/42",
|
|
99
|
+
"https://gitlab.com/group/project/-/merge_requests/18",
|
|
100
|
+
"wiki/30-usb-protocol.md"
|
|
101
|
+
]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Include paths to:
|
|
105
|
+
- Source files being modified
|
|
106
|
+
- Issues or MRs related to the task
|
|
107
|
+
- Wiki pages or docs that provide context
|
|
108
|
+
- External references (datasheets, specs)
|
|
109
|
+
|
|
110
|
+
### priority — Estimation Rules
|
|
111
|
+
|
|
112
|
+
| Priority | When to use |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `high` | Blocks other work, causes failures, user explicitly flagged as urgent, safety/security |
|
|
115
|
+
| `medium` | Improves existing functionality, needed for the current milestone, moderate impact |
|
|
116
|
+
| `low` | Nice-to-have, cosmetic, future consideration, no immediate impact |
|
|
117
|
+
|
|
118
|
+
Signals in source text:
|
|
119
|
+
- "must", "critical", "blocker", "breaks", "urgent" -> `high`
|
|
120
|
+
- "should", "improve", "enhance", "needed" -> `medium`
|
|
121
|
+
- "could", "maybe", "eventually", "nice to have", "low priority" -> `low`
|
|
122
|
+
|
|
123
|
+
When ambiguous, default to `medium`.
|
|
124
|
+
|
|
125
|
+
### session — Decisions, Blockers, Notes
|
|
126
|
+
|
|
127
|
+
| Field | What to extract |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `summary` | One-line overview with `[import]` prefix |
|
|
130
|
+
| `decisions` | Technical choices with `reason` and `confidence` (`confirmed`/`estimated`/`unverified`) |
|
|
131
|
+
| `blockers` | Anything preventing progress (dependencies, missing info, hardware) |
|
|
132
|
+
| `handoff_notes` | `caution`: risks and warnings. `context`: background info. `suggestion`: concrete next actions |
|
|
133
|
+
| `references` | Documents, issues, MRs, wikis relevant to the import |
|
|
134
|
+
| `context_pointers` | Files the next session should read first, with line ranges if known |
|
|
135
|
+
|
|
136
|
+
### raw_notes — The Safety Net
|
|
137
|
+
|
|
138
|
+
Anything that doesn't fit the structured fields goes into `raw_notes`.
|
|
139
|
+
Never discard information from the source — if it can't be structured,
|
|
140
|
+
preserve it as raw text.
|
|
141
|
+
|
|
142
|
+
## source Field
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"description": "Import from <filepath>",
|
|
147
|
+
"format": "markdown"
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Detect format from file extension: `.md` -> `markdown`, `.json` -> `json`,
|
|
152
|
+
`.txt` -> `text`, other -> `other`.
|
|
153
|
+
|
|
154
|
+
## Structuring Guidelines
|
|
155
|
+
|
|
156
|
+
- **When in doubt, make it a task**: Register borderline items as tasks with
|
|
157
|
+
context in notes. They can be `skipped` later.
|
|
158
|
+
- **Never discard info**: Everything from the source must land somewhere —
|
|
159
|
+
tasks, session, or raw_notes.
|
|
160
|
+
- **Always reference the source**: Include the source file path in `references`.
|
|
161
|
+
- **Mark estimates**: When status or priority is inferred, note "(estimated)"
|
|
162
|
+
in the task's notes.
|
|
163
|
+
|
|
164
|
+
## Full Example
|
|
165
|
+
|
|
166
|
+
**Source document** (`tmp/260610-sprint-handoff.md`):
|
|
167
|
+
> ## Current work
|
|
168
|
+
> Auth module rewrite is 80% done. Decided on OAuth2+PKCE for mobile support.
|
|
169
|
+
> Token refresh tests still failing — need to mock the expiry clock.
|
|
170
|
+
>
|
|
171
|
+
> ## Tasks
|
|
172
|
+
> - [x] Session migration script (deployed to prod)
|
|
173
|
+
> - [ ] PKCE flow (frontend + backend)
|
|
174
|
+
> - [ ] CI pipeline takes 12min, target is 5min
|
|
175
|
+
>
|
|
176
|
+
> ## Blockers
|
|
177
|
+
> - DB migration window not scheduled yet
|
|
178
|
+
|
|
179
|
+
**Structured call**:
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"source": {"description": "tmp/260610-sprint-handoff.md", "format": "markdown"},
|
|
183
|
+
"tasks": [
|
|
184
|
+
{
|
|
185
|
+
"title": "Auth module rewrite",
|
|
186
|
+
"status": "in_progress",
|
|
187
|
+
"priority": "high",
|
|
188
|
+
"notes": "80% done. Token refresh tests failing due to clock mocking.",
|
|
189
|
+
"labels": ["auth", "security"],
|
|
190
|
+
"links": ["src/auth/oauth.rs", "src/auth/token.rs"],
|
|
191
|
+
"done_criteria": [
|
|
192
|
+
{"item": "Token refresh test passes with mocked expiry clock", "checked": false},
|
|
193
|
+
{"item": "OAuth2 PKCE flow works on mobile client", "checked": false}
|
|
194
|
+
],
|
|
195
|
+
"children": [
|
|
196
|
+
{"title": "Session migration script", "status": "done", "notes": "Deployed to prod"},
|
|
197
|
+
{
|
|
198
|
+
"title": "PKCE flow implementation",
|
|
199
|
+
"status": "in_progress",
|
|
200
|
+
"priority": "high",
|
|
201
|
+
"children": [
|
|
202
|
+
{"title": "Frontend PKCE integration", "status": "todo"},
|
|
203
|
+
{"title": "Backend PKCE endpoints", "status": "todo"}
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
]
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"title": "CI pipeline optimization",
|
|
210
|
+
"status": "todo",
|
|
211
|
+
"priority": "medium",
|
|
212
|
+
"labels": ["ci"],
|
|
213
|
+
"done_criteria": [
|
|
214
|
+
{"item": "CI build time under 5 minutes", "checked": false}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
"session": {
|
|
219
|
+
"summary": "[import] Sprint handoff migration from tmp/260610",
|
|
220
|
+
"decisions": [
|
|
221
|
+
{"decision": "OAuth2 + PKCE for auth", "reason": "Mobile app needs PKCE; implicit flow not viable", "confidence": "confirmed"}
|
|
222
|
+
],
|
|
223
|
+
"blockers": ["DB migration window not scheduled"],
|
|
224
|
+
"references": [
|
|
225
|
+
{"label": "Source handoff doc", "uri": "tmp/260610-sprint-handoff.md", "type": "doc"}
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Common Mistakes
|
|
232
|
+
|
|
233
|
+
- **Empty done_criteria**: Every `todo`/`in_progress` task should have at least one criterion.
|
|
234
|
+
- **Missing links**: If the source mentions files or issues, capture them in `links`.
|
|
235
|
+
- **Generic priority**: Don't leave priority empty. Apply the rules above.
|
|
236
|
+
- **Flat structure**: If tasks have natural parent-child relationships, use `children`.
|
|
237
|
+
- **Discarding info**: Use `raw_notes` for anything that doesn't fit structured fields.
|
|
238
|
+
- **Missing schedule fields**: If the source mentions estimates, deadlines, or milestones, capture them.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: handoff-load
|
|
3
|
+
description: "Load handoff context for the current project. Calls handoff_load_context, summarizes tasks/decisions/blockers, and shows what to work on next. Triggers on '/handoff-load', 'コンテキスト読み込み', 'what was I working on', 'resume work'."
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Handoff Load
|
|
8
|
+
|
|
9
|
+
セッション開始時にプロジェクトの引き継ぎコンテキストを読み込み、現状を把握する。
|
|
10
|
+
|
|
11
|
+
## 手順
|
|
12
|
+
|
|
13
|
+
1. `handoff_load_context` を呼ぶ(引数なし — cwd を使用)
|
|
14
|
+
2. `not_initialized` が返った場合:
|
|
15
|
+
- ディレクトリ名からプロジェクト名を推測
|
|
16
|
+
- `handoff_init` で初期化
|
|
17
|
+
- 初期化完了を報告して終了
|
|
18
|
+
3. **paused セッション**がある場合:
|
|
19
|
+
- `paused_sessions` をユーザーに表示(ID, summary, branch)
|
|
20
|
+
- 再開したい場合: `handoff_load_context(session_id: "s-...")` で paused → active に遷移
|
|
21
|
+
- 不要な場合: `handoff_save_context(close_session_id: "s-...")` で paused → closed に直接遷移
|
|
22
|
+
4. **`session_guidance` がある場合(アクティブセッション未確立)**:
|
|
23
|
+
- 作業開始前に `handoff_save_context` を `session_status: "active"` で呼んでセッションを確立する
|
|
24
|
+
- `session_guidance.suggested_fields` の内容(summary, decisions, context_pointers, references, related_task_ids)を引き継いで含める
|
|
25
|
+
- `handoff_notes` に最低1つ `suggestion`(これから何をするか)を含める
|
|
26
|
+
- これにより中断時にも「何をしようとしていたか」が `.handoff/` に残る
|
|
27
|
+
5. 返されたコンテキストを以下の順で確認・要約:
|
|
28
|
+
- **前回セッション / previous_session**: summary, branch, commit, ended_at。引き継ぎ情報(decisions, handoff_notes, context_pointers)を確認
|
|
29
|
+
- **タスク**: `blocked` → `in_progress` → `todo` の優先順で表示
|
|
30
|
+
- **工数サマリー**: total_estimate_hours, total_actual_hours, completion_rate, overdue_count を報告
|
|
31
|
+
- **期限超過**: overdue_count > 0 なら該当タスクを強調
|
|
32
|
+
- **ブロッカー**: あれば強調表示
|
|
33
|
+
- **決定事項**: confidence が `unverified` のものを警告
|
|
34
|
+
- **申し送り**: `caution` を最初に、`context` → `suggestion` の順
|
|
35
|
+
- **コンテキストポインタ**: 次に読むべきファイル一覧
|
|
36
|
+
6. ユーザーに現状サマリーを日本語で報告
|
|
37
|
+
7. 次のアクションを提案(「何から始めますか?」)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: handoff-refer
|
|
3
|
+
description: "Send a cross-project referral to another project's .handoff/. Triggers on 'send referral', 'refer to <project>', 'cross-project', 'notify other project', or when work in one project reveals an issue/improvement needed in another."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Handoff Refer Skill
|
|
7
|
+
|
|
8
|
+
Send a structured referral (improvement request, bug report, work request)
|
|
9
|
+
from the current project to another project that uses handoff-mcp.
|
|
10
|
+
|
|
11
|
+
## When to Use
|
|
12
|
+
|
|
13
|
+
- You discover a bug in a dependency project during work
|
|
14
|
+
- You identify an improvement needed in another project
|
|
15
|
+
- You want to request work from another team/project
|
|
16
|
+
- Cross-project coordination is needed
|
|
17
|
+
|
|
18
|
+
## Procedure
|
|
19
|
+
|
|
20
|
+
1. Identify the target project (by name or path).
|
|
21
|
+
2. Determine the referral type: `improvement`, `bug`, `request`, or `info`.
|
|
22
|
+
3. Call `handoff_refer` with **all required fields**:
|
|
23
|
+
- `summary`: one-line description
|
|
24
|
+
- `referral_type`: category
|
|
25
|
+
- `priority`: `low`, `medium`, or `high`
|
|
26
|
+
- `target_project` (name) or `target_project_dir` (path)
|
|
27
|
+
- `details`: what changed, its impact, and what the target needs to do
|
|
28
|
+
- `tasks`: concrete work items with `done_criteria`
|
|
29
|
+
- `context`: source references (branch, commit, spec docs, MR links)
|
|
30
|
+
4. If warnings are returned, fix them before proceeding. The server
|
|
31
|
+
validates completeness — omitting `details`, `tasks`, `context`, or
|
|
32
|
+
`priority` triggers warnings. Tasks without `done_criteria` also warn.
|
|
33
|
+
5. Report confirmation to the user.
|
|
34
|
+
|
|
35
|
+
## Target Resolution
|
|
36
|
+
|
|
37
|
+
- **By name**: `target_project: "pochi-dio"` — resolved via `scan_dirs` in config
|
|
38
|
+
- **By path**: `target_project_dir: "/home/user/pro/pochi-dio"` — direct path
|
|
39
|
+
|
|
40
|
+
Use name when the project is in a scan_dirs directory.
|
|
41
|
+
Use path when targeting a project outside scan_dirs.
|
|
42
|
+
|
|
43
|
+
## Managing Received Referrals
|
|
44
|
+
|
|
45
|
+
When `handoff_load_context` shows incoming referrals:
|
|
46
|
+
|
|
47
|
+
1. Review each referral's summary and priority.
|
|
48
|
+
2. Acknowledge with `handoff_update_referral` (status: `acknowledged`).
|
|
49
|
+
3. Create tasks based on the referral if appropriate.
|
|
50
|
+
4. Resolve with `handoff_update_referral` (status: `resolved`) when done.
|
|
51
|
+
|
|
52
|
+
## Example
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"target_project": "handoff-mcp",
|
|
57
|
+
"summary": "Import skill needs better task structuring guidance",
|
|
58
|
+
"referral_type": "improvement",
|
|
59
|
+
"priority": "medium",
|
|
60
|
+
"details": "When importing 20 tasks from pochi-dio, done_criteria and links were mostly empty.",
|
|
61
|
+
"tasks": [
|
|
62
|
+
{
|
|
63
|
+
"title": "Add before/after examples to import skill",
|
|
64
|
+
"priority": "medium",
|
|
65
|
+
"done_criteria": [
|
|
66
|
+
{"item": "Skill includes concrete field mapping examples"}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
```
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
2
|
+
use std::path::{Path, PathBuf};
|
|
3
|
+
|
|
4
|
+
use anyhow::{Context, Result};
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
use toml_edit::{DocumentMut, Item, Table};
|
|
7
|
+
|
|
8
|
+
use super::config_crud::{
|
|
9
|
+
load_doc, require_str, save_doc, set_opt_f64, set_opt_str, set_string_array,
|
|
10
|
+
};
|
|
11
|
+
use super::resolve_project_dir;
|
|
12
|
+
use crate::storage::ensure_handoff_exists;
|
|
13
|
+
use crate::storage::tasks::{build_task_index, TaskIndex};
|
|
14
|
+
|
|
15
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
16
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
17
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
18
|
+
let config_path = handoff.join("config.toml");
|
|
19
|
+
let tasks_dir = handoff.join("tasks");
|
|
20
|
+
|
|
21
|
+
let raw = std::fs::read_to_string(&config_path)
|
|
22
|
+
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
|
|
23
|
+
let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config.toml")?;
|
|
24
|
+
|
|
25
|
+
let assignees_table = doc.get("assignees").and_then(|v| v.as_table());
|
|
26
|
+
|
|
27
|
+
let mut result: HashMap<String, Value> = HashMap::new();
|
|
28
|
+
|
|
29
|
+
if let Some(table) = assignees_table {
|
|
30
|
+
for (key, item) in table.iter() {
|
|
31
|
+
let sub = match item.as_table() {
|
|
32
|
+
Some(t) => t,
|
|
33
|
+
None => continue,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let display_name = sub
|
|
37
|
+
.get("display_name")
|
|
38
|
+
.and_then(|v| v.as_str())
|
|
39
|
+
.unwrap_or(key);
|
|
40
|
+
let color = sub.get("color").and_then(|v| v.as_str()).unwrap_or("");
|
|
41
|
+
let work_hours = sub
|
|
42
|
+
.get("work_hours_per_day")
|
|
43
|
+
.and_then(|v| v.as_integer().or_else(|| v.as_float().map(|f| f as i64)))
|
|
44
|
+
.unwrap_or(8);
|
|
45
|
+
let closed_weekdays: Vec<Value> = sub
|
|
46
|
+
.get("closed_weekdays")
|
|
47
|
+
.and_then(|v| v.as_array())
|
|
48
|
+
.map(|arr| {
|
|
49
|
+
arr.iter()
|
|
50
|
+
.filter_map(|v| {
|
|
51
|
+
v.as_str()
|
|
52
|
+
.map(|s| json!(s))
|
|
53
|
+
.or_else(|| v.as_integer().map(|n| json!(n)))
|
|
54
|
+
})
|
|
55
|
+
.collect()
|
|
56
|
+
})
|
|
57
|
+
.unwrap_or_default();
|
|
58
|
+
|
|
59
|
+
result.insert(
|
|
60
|
+
key.to_string(),
|
|
61
|
+
json!({
|
|
62
|
+
"display_name": display_name,
|
|
63
|
+
"color": color,
|
|
64
|
+
"work_hours_per_day": work_hours,
|
|
65
|
+
"closed_weekdays": closed_weekdays,
|
|
66
|
+
"task_count": 0,
|
|
67
|
+
"active_task_count": 0,
|
|
68
|
+
"total_estimate_hours": 0.0,
|
|
69
|
+
"total_actual_hours": 0.0,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Count tasks per assignee
|
|
76
|
+
if tasks_dir.exists() {
|
|
77
|
+
let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
|
|
78
|
+
count_assignee_tasks(&tree, &mut result);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let output = json!({ "assignees": result });
|
|
82
|
+
serde_json::to_string_pretty(&output).map_err(Into::into)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn count_assignee_tasks(tree: &[TaskIndex], result: &mut HashMap<String, Value>) {
|
|
86
|
+
for node in tree {
|
|
87
|
+
if let Some(ref assignee) = node.assignee {
|
|
88
|
+
if let Some(entry) = result.get_mut(assignee) {
|
|
89
|
+
if let Some(obj) = entry.as_object_mut() {
|
|
90
|
+
let tc = obj.get("task_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
|
91
|
+
obj.insert("task_count".to_string(), json!(tc + 1));
|
|
92
|
+
|
|
93
|
+
if node.status == "in_progress" || node.status == "review" {
|
|
94
|
+
let ac = obj
|
|
95
|
+
.get("active_task_count")
|
|
96
|
+
.and_then(|v| v.as_u64())
|
|
97
|
+
.unwrap_or(0);
|
|
98
|
+
obj.insert("active_task_count".to_string(), json!(ac + 1));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if let Some(ref sched) = node.schedule {
|
|
102
|
+
if let Some(est) = sched.estimate_hours {
|
|
103
|
+
let cur = obj
|
|
104
|
+
.get("total_estimate_hours")
|
|
105
|
+
.and_then(|v| v.as_f64())
|
|
106
|
+
.unwrap_or(0.0);
|
|
107
|
+
obj.insert("total_estimate_hours".to_string(), json!(cur + est));
|
|
108
|
+
}
|
|
109
|
+
if let Some(act) = sched.actual_hours {
|
|
110
|
+
let cur = obj
|
|
111
|
+
.get("total_actual_hours")
|
|
112
|
+
.and_then(|v| v.as_f64())
|
|
113
|
+
.unwrap_or(0.0);
|
|
114
|
+
obj.insert("total_actual_hours".to_string(), json!(cur + act));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
count_assignee_tasks(&node.children, result);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// handoff_add_assignee — create a new `[assignees.<key>]` entry. Fails if the
|
|
126
|
+
/// key already exists.
|
|
127
|
+
pub fn handle_add(arguments: &Value) -> Result<String> {
|
|
128
|
+
let path = super::config_crud::config_path(arguments)?;
|
|
129
|
+
let key = require_str(arguments, "key")?;
|
|
130
|
+
let mut doc = load_doc(&path)?;
|
|
131
|
+
|
|
132
|
+
let assignees = super::config_crud::ensure_table(&mut doc, "assignees")?;
|
|
133
|
+
assignees.set_implicit(true);
|
|
134
|
+
if assignees.contains_key(key) {
|
|
135
|
+
anyhow::bail!("Assignee '{key}' already exists. Use handoff_update_assignee to modify it.");
|
|
136
|
+
}
|
|
137
|
+
let mut sub = Table::new();
|
|
138
|
+
apply_assignee_fields(&mut sub, arguments);
|
|
139
|
+
doc["assignees"][key] = Item::Table(sub);
|
|
140
|
+
|
|
141
|
+
save_doc(&path, &doc)?;
|
|
142
|
+
Ok(format!("Added assignee '{key}'"))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// handoff_update_assignee — patch an existing `[assignees.<key>]` entry.
|
|
146
|
+
pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
147
|
+
let path = super::config_crud::config_path(arguments)?;
|
|
148
|
+
let key = require_str(arguments, "key")?;
|
|
149
|
+
let mut doc = load_doc(&path)?;
|
|
150
|
+
|
|
151
|
+
let exists = doc
|
|
152
|
+
.get("assignees")
|
|
153
|
+
.and_then(|v| v.as_table())
|
|
154
|
+
.map(|t| t.contains_key(key))
|
|
155
|
+
.unwrap_or(false);
|
|
156
|
+
if !exists {
|
|
157
|
+
anyhow::bail!("Assignee '{key}' not found. Use handoff_add_assignee to create it.");
|
|
158
|
+
}
|
|
159
|
+
let sub = super::config_crud::ensure_subtable(&mut doc, "assignees", key)?;
|
|
160
|
+
apply_assignee_fields(sub, arguments);
|
|
161
|
+
|
|
162
|
+
save_doc(&path, &doc)?;
|
|
163
|
+
Ok(format!("Updated assignee '{key}'"))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// handoff_remove_assignee — delete a `[assignees.<key>]` entry and unassign it
|
|
167
|
+
/// from every task (matches the VSCode extension's removeAssignee behaviour).
|
|
168
|
+
pub fn handle_remove(arguments: &Value) -> Result<String> {
|
|
169
|
+
let path = super::config_crud::config_path(arguments)?;
|
|
170
|
+
let key = require_str(arguments, "key")?;
|
|
171
|
+
let mut doc = load_doc(&path)?;
|
|
172
|
+
|
|
173
|
+
let removed = doc
|
|
174
|
+
.get_mut("assignees")
|
|
175
|
+
.and_then(|v| v.as_table_mut())
|
|
176
|
+
.map(|t| t.remove(key).is_some())
|
|
177
|
+
.unwrap_or(false);
|
|
178
|
+
if !removed {
|
|
179
|
+
anyhow::bail!("Assignee '{key}' not found.");
|
|
180
|
+
}
|
|
181
|
+
save_doc(&path, &doc)?;
|
|
182
|
+
|
|
183
|
+
// Unassign from tasks.
|
|
184
|
+
let tasks_dir = path
|
|
185
|
+
.parent()
|
|
186
|
+
.map(|p| p.join("tasks"))
|
|
187
|
+
.ok_or_else(|| anyhow::anyhow!("Cannot locate tasks dir"))?;
|
|
188
|
+
let unassigned = unassign_all(&tasks_dir, key)?;
|
|
189
|
+
|
|
190
|
+
Ok(format!(
|
|
191
|
+
"Removed assignee '{key}' and unassigned it from {unassigned} task(s)"
|
|
192
|
+
))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Apply the optional assignee fields from `arguments` onto a TOML table.
|
|
196
|
+
fn apply_assignee_fields(table: &mut Table, arguments: &Value) {
|
|
197
|
+
table.set_implicit(false);
|
|
198
|
+
set_opt_str(table, "display_name", arguments.get("display_name"));
|
|
199
|
+
set_opt_str(table, "color", arguments.get("color"));
|
|
200
|
+
set_opt_f64(
|
|
201
|
+
table,
|
|
202
|
+
"work_hours_per_day",
|
|
203
|
+
arguments.get("work_hours_per_day"),
|
|
204
|
+
);
|
|
205
|
+
super::config_crud::set_mixed_array(table, "closed_weekdays", arguments.get("closed_weekdays"));
|
|
206
|
+
set_string_array(table, "closed_dates", arguments.get("closed_dates"));
|
|
207
|
+
set_string_array(table, "open_dates", arguments.get("open_dates"));
|
|
208
|
+
super::config_crud::set_f64_map(table, "day_hours", arguments.get("day_hours"));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Clear `assignee` on every task currently assigned to `key`. Returns the count.
|
|
212
|
+
fn unassign_all(tasks_dir: &Path, key: &str) -> Result<usize> {
|
|
213
|
+
use crate::storage::tasks::{read_task, write_task};
|
|
214
|
+
use chrono::Utc;
|
|
215
|
+
|
|
216
|
+
let mut count = 0;
|
|
217
|
+
let dirs = collect_task_dirs(tasks_dir)?;
|
|
218
|
+
for dir in dirs {
|
|
219
|
+
if let Some((mut data, status)) = read_task(&dir)? {
|
|
220
|
+
if data.assignee.as_deref() == Some(key) {
|
|
221
|
+
data.assignee = None;
|
|
222
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
223
|
+
write_task(&dir, &status, &data)?;
|
|
224
|
+
count += 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
Ok(count)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// Recursively collect every task directory under `tasks_dir`.
|
|
232
|
+
fn collect_task_dirs(dir: &Path) -> Result<Vec<PathBuf>> {
|
|
233
|
+
let mut out = Vec::new();
|
|
234
|
+
collect_task_dirs_into(dir, &mut out)?;
|
|
235
|
+
Ok(out)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
fn collect_task_dirs_into(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
|
239
|
+
if !dir.exists() {
|
|
240
|
+
return Ok(());
|
|
241
|
+
}
|
|
242
|
+
for entry in std::fs::read_dir(dir)? {
|
|
243
|
+
let entry = entry?;
|
|
244
|
+
let path = entry.path();
|
|
245
|
+
if path.is_dir() {
|
|
246
|
+
// A task directory contains a `_task.<status>.json` file.
|
|
247
|
+
if crate::storage::tasks::find_task_file(&path)?.is_some() {
|
|
248
|
+
out.push(path.clone());
|
|
249
|
+
}
|
|
250
|
+
collect_task_dirs_into(&path, out)?;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
Ok(())
|
|
254
|
+
}
|