handoff-mcp-server 0.8.1 → 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.
@@ -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
+ }