taskcheck 1.5.1__tar.gz → 1.6.0__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 (34) hide show
  1. {taskcheck-1.5.1/taskcheck.egg-info → taskcheck-1.6.0}/PKG-INFO +42 -15
  2. {taskcheck-1.5.1 → taskcheck-1.6.0}/README.md +41 -14
  3. {taskcheck-1.5.1 → taskcheck-1.6.0}/pyproject.toml +1 -1
  4. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/__main__.py +1 -0
  5. taskcheck-1.6.0/taskcheck/agent_blocks.py +160 -0
  6. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/common.py +19 -1
  7. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/install.py +4 -1
  8. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/parallel.py +24 -0
  9. {taskcheck-1.5.1 → taskcheck-1.6.0/taskcheck.egg-info}/PKG-INFO +42 -15
  10. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck.egg-info/SOURCES.txt +3 -0
  11. taskcheck-1.6.0/tests/test_agent_blocks.py +196 -0
  12. taskcheck-1.6.0/tests/test_agent_scheduler_integration.py +153 -0
  13. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_main.py +3 -0
  14. {taskcheck-1.5.1 → taskcheck-1.6.0}/LICENSE +0 -0
  15. {taskcheck-1.5.1 → taskcheck-1.6.0}/setup.cfg +0 -0
  16. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/dry_run.py +0 -0
  17. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/google_calendar.py +0 -0
  18. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/ical.py +0 -0
  19. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/report.py +0 -0
  20. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/theme.py +0 -0
  21. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/timeline.py +0 -0
  22. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck/undo.py +0 -0
  23. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck.egg-info/dependency_links.txt +0 -0
  24. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck.egg-info/entry_points.txt +0 -0
  25. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck.egg-info/requires.txt +0 -0
  26. {taskcheck-1.5.1 → taskcheck-1.6.0}/taskcheck.egg-info/top_level.txt +0 -0
  27. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_common.py +0 -0
  28. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_google_calendar.py +0 -0
  29. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_ical.py +0 -0
  30. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_install.py +0 -0
  31. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_parallel.py +0 -0
  32. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_report.py +0 -0
  33. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_timeline.py +0 -0
  34. {taskcheck-1.5.1 → taskcheck-1.6.0}/tests/test_undo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.5.1
3
+ Version: 1.6.0
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -13,8 +13,6 @@ Requires-Dist: random-unicode-emoji>=2.8
13
13
  Requires-Dist: requests>=2.32.3
14
14
  Requires-Dist: rich>=13.9.4
15
15
 
16
- ![immagine](https://github.com/user-attachments/assets/27b83bb1-7a50-4923-a453-0a958fbe11ed)
17
-
18
16
  A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
19
17
  It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
20
18
 
@@ -23,6 +21,7 @@ Use it if you want to:
23
21
  - fit tasks into real availability
24
22
  - be sure your long-time plan won't go overdue
25
23
  - keep Taskwarrior as the source of truth
24
+ - handle your time across multiple tasks carried on by LLMs
26
25
 
27
26
  > [!IMPORTANT]
28
27
  > This repo is actively maintained again
@@ -30,7 +29,7 @@ Use it if you want to:
30
29
  ## Features
31
30
 
32
31
  - ✨ Auto-schedule tasks from working hours + calendar blocks
33
- - 🧭 Support complex working-hour maps
32
+ - 🧠 Schedule agent-assisted tasks alongside your own, based on how much supervision they need
34
33
  - ⏱️ Consider urgency, due dates, and dependencies
35
34
  - 🧪 Persisted dry-run previews for reports and timelines
36
35
  - ↩️ Undo the latest schedule update
@@ -41,19 +40,16 @@ Use it if you want to:
41
40
  - 🎨 Report styling with emoji and extra attributes
42
41
  - 🗓️ Block time with iCal or Google Calendar, including all-day events
43
42
 
44
- ## Quick start
43
+ ![Dry-run schedule and report](docs/images/dry-run-report.png)
45
44
 
46
- 1. `pipx install taskcheck`
47
- 2. `taskcheck --install` ← setup only
48
- 3. add `estimated` + `time_map` UDAs
49
- 4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
50
- 5. `taskcheck --schedule`
51
- 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
45
+ ![Daily timeline](docs/images/timeline-day.png)
52
46
 
53
47
  ## How it works
54
48
 
55
- Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes back a schedule.
56
- It respects:
49
+ Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
50
+ back a schedule. For tasks tagged `+agent`, it treats `estimated` as the agent's work time and
51
+ `agent_weight` as the percentage of that time needing your attention. It respects:
52
+
57
53
  - your working hours
58
54
  - task duration estimates
59
55
  - calendar events and vacations from iCal / Google Calendar
@@ -64,10 +60,21 @@ It updates Taskwarrior with:
64
60
  - `completion_date` → when work is expected to finish
65
61
  - a warning when a task will miss its due date
66
62
 
63
+ ## Quick start
64
+
65
+ 1. `pipx install taskcheck`
66
+ 2. `taskcheck --install` ← setup only: registers required Taskwarrior settings inyour taskwarrior config file and offers to create the
67
+ config file; it does not schedule tasks.
68
+ 3. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
69
+ 4. tune your tasks: add `estimated`, `time_map`, `agent_weight` UDAs and the `+agent` tag (`time_map` and `agent_weight` may use the default value defined in the Taskwarrior settings)
70
+ 5. `taskcheck --schedule`
71
+ 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
72
+
67
73
  ### Required UDAs
68
74
 
69
75
  - `estimated`: expected duration in hours
70
76
  - `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
77
+ - `agent_weight`: required for `+agent` tasks; the percentage (0–100) of agent work time that needs your attention. It defaults to 25 when using `taskcheck --install`.
71
78
 
72
79
  Example:
73
80
 
@@ -77,6 +84,20 @@ monday = [[9, 12.30], [14, 17]]
77
84
  tuesday = [[9, 12.30], [14, 17]]
78
85
  ```
79
86
 
87
+ ### Agent tasks and mixed blocks
88
+
89
+ Mark a task `+agent` when a computer helper will do the work, and set `agent_weight` to show what
90
+ percentage of its work time needs your attention. For example, `10` means you spend about 10 minutes
91
+ interacting with the agent for each hour the agent works. Taskcheck can plan several agent jobs at
92
+ once, fill the time left with your own tasks, and respect waiting dates and dependencies.
93
+
94
+ ```bash
95
+ task add "Implement API" +agent estimated:5h agent_weight:10
96
+ task add "Review results" estimated:1h
97
+ ```
98
+
99
+ Run `taskcheck --install` to register the `agent_weight` Taskwarrior UDA.
100
+
80
101
  ### Reports and timelines
81
102
 
82
103
  - `taskcheck -r today` → tasks planned for today
@@ -84,9 +105,15 @@ tuesday = [[9, 12.30], [14, 17]]
84
105
  - `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
85
106
  - `taskcheck --timeline eom --zoom week` → weekly timeline through month end
86
107
 
87
- Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report today --dry-run` or `taskcheck --timeline 1w --dry-run`.
108
+ Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
109
+ saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
110
+ today --dry-run` or `taskcheck --timeline 1w --dry-run`.
88
111
 
89
- Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most detailed view that fits the terminal width is selected: day, then week, then month. The track uses `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules separate days, weeks, and months. Project colors are deterministic and adapt to light or dark terminals through OSC 11, with config and environment fallbacks.
112
+ Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
113
+ detailed view that fits the terminal width is selected: day, then week, then month. The track uses
114
+ `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
115
+ separate days, weeks, and months. Project colors are deterministic and adapt to light or dark
116
+ terminals through OSC 11, with config and environment fallbacks.
90
117
 
91
118
  ## Reference
92
119
 
@@ -1,5 +1,3 @@
1
- ![immagine](https://github.com/user-attachments/assets/27b83bb1-7a50-4923-a453-0a958fbe11ed)
2
-
3
1
  A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
4
2
  It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
5
3
 
@@ -8,6 +6,7 @@ Use it if you want to:
8
6
  - fit tasks into real availability
9
7
  - be sure your long-time plan won't go overdue
10
8
  - keep Taskwarrior as the source of truth
9
+ - handle your time across multiple tasks carried on by LLMs
11
10
 
12
11
  > [!IMPORTANT]
13
12
  > This repo is actively maintained again
@@ -15,7 +14,7 @@ Use it if you want to:
15
14
  ## Features
16
15
 
17
16
  - ✨ Auto-schedule tasks from working hours + calendar blocks
18
- - 🧭 Support complex working-hour maps
17
+ - 🧠 Schedule agent-assisted tasks alongside your own, based on how much supervision they need
19
18
  - ⏱️ Consider urgency, due dates, and dependencies
20
19
  - 🧪 Persisted dry-run previews for reports and timelines
21
20
  - ↩️ Undo the latest schedule update
@@ -26,19 +25,16 @@ Use it if you want to:
26
25
  - 🎨 Report styling with emoji and extra attributes
27
26
  - 🗓️ Block time with iCal or Google Calendar, including all-day events
28
27
 
29
- ## Quick start
28
+ ![Dry-run schedule and report](docs/images/dry-run-report.png)
30
29
 
31
- 1. `pipx install taskcheck`
32
- 2. `taskcheck --install` ← setup only
33
- 3. add `estimated` + `time_map` UDAs
34
- 4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
35
- 5. `taskcheck --schedule`
36
- 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
30
+ ![Daily timeline](docs/images/timeline-day.png)
37
31
 
38
32
  ## How it works
39
33
 
40
- Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes back a schedule.
41
- It respects:
34
+ Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
35
+ back a schedule. For tasks tagged `+agent`, it treats `estimated` as the agent's work time and
36
+ `agent_weight` as the percentage of that time needing your attention. It respects:
37
+
42
38
  - your working hours
43
39
  - task duration estimates
44
40
  - calendar events and vacations from iCal / Google Calendar
@@ -49,10 +45,21 @@ It updates Taskwarrior with:
49
45
  - `completion_date` → when work is expected to finish
50
46
  - a warning when a task will miss its due date
51
47
 
48
+ ## Quick start
49
+
50
+ 1. `pipx install taskcheck`
51
+ 2. `taskcheck --install` ← setup only: registers required Taskwarrior settings inyour taskwarrior config file and offers to create the
52
+ config file; it does not schedule tasks.
53
+ 3. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
54
+ 4. tune your tasks: add `estimated`, `time_map`, `agent_weight` UDAs and the `+agent` tag (`time_map` and `agent_weight` may use the default value defined in the Taskwarrior settings)
55
+ 5. `taskcheck --schedule`
56
+ 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
57
+
52
58
  ### Required UDAs
53
59
 
54
60
  - `estimated`: expected duration in hours
55
61
  - `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
62
+ - `agent_weight`: required for `+agent` tasks; the percentage (0–100) of agent work time that needs your attention. It defaults to 25 when using `taskcheck --install`.
56
63
 
57
64
  Example:
58
65
 
@@ -62,6 +69,20 @@ monday = [[9, 12.30], [14, 17]]
62
69
  tuesday = [[9, 12.30], [14, 17]]
63
70
  ```
64
71
 
72
+ ### Agent tasks and mixed blocks
73
+
74
+ Mark a task `+agent` when a computer helper will do the work, and set `agent_weight` to show what
75
+ percentage of its work time needs your attention. For example, `10` means you spend about 10 minutes
76
+ interacting with the agent for each hour the agent works. Taskcheck can plan several agent jobs at
77
+ once, fill the time left with your own tasks, and respect waiting dates and dependencies.
78
+
79
+ ```bash
80
+ task add "Implement API" +agent estimated:5h agent_weight:10
81
+ task add "Review results" estimated:1h
82
+ ```
83
+
84
+ Run `taskcheck --install` to register the `agent_weight` Taskwarrior UDA.
85
+
65
86
  ### Reports and timelines
66
87
 
67
88
  - `taskcheck -r today` → tasks planned for today
@@ -69,9 +90,15 @@ tuesday = [[9, 12.30], [14, 17]]
69
90
  - `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
70
91
  - `taskcheck --timeline eom --zoom week` → weekly timeline through month end
71
92
 
72
- Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report today --dry-run` or `taskcheck --timeline 1w --dry-run`.
93
+ Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
94
+ saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
95
+ today --dry-run` or `taskcheck --timeline 1w --dry-run`.
73
96
 
74
- Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most detailed view that fits the terminal width is selected: day, then week, then month. The track uses `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules separate days, weeks, and months. Project colors are deterministic and adapt to light or dark terminals through OSC 11, with config and environment fallbacks.
97
+ Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
98
+ detailed view that fits the terminal width is selected: day, then week, then month. The track uses
99
+ `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
100
+ separate days, weeks, and months. Project colors are deterministic and adapt to light or dark
101
+ terminals through OSC 11, with config and environment fallbacks.
75
102
 
76
103
  ## Reference
77
104
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskcheck"
3
- version = "1.5.1"
3
+ version = "1.6.0"
4
4
  description = "Automatic scheduler for taskwarrior"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -63,6 +63,7 @@ arg_parser.add_argument(
63
63
  help="Weight for urgency in scheduling (0.0 to 1.0), overrides config value. When 1.0, the whole Taskwarrior urgency is used for scheduling. When 0.0, the Taskwarrior urgency is reduced to only due urgency.",
64
64
  )
65
65
  arg_parser.add_argument(
66
+ "-d",
66
67
  "--dry-run",
67
68
  action="store_true",
68
69
  help="Perform scheduling without modifying the Taskwarrior database, useful for testing.",
@@ -0,0 +1,160 @@
1
+ """Plan mixed agent/human work inside an ordinary Taskcheck block.
2
+
3
+ All durations are hours. Agent estimates describe elapsed execution time while
4
+ agent_weight is the percentage of that time needing human interaction. A block
5
+ consumes its full elapsed duration from the calendar, not the sum of the task
6
+ estimates. Internal allocations never create scheduling sub-blocks.
7
+ """
8
+
9
+ from datetime import datetime
10
+ from math import isfinite
11
+
12
+ from taskcheck.common import hours_to_decimal
13
+
14
+ _EPSILON = 1e-9
15
+
16
+
17
+ def get_agent_weight(task):
18
+ """Return human-interaction fraction for +agent tasks, else None.
19
+
20
+ A tagged task must specify a percentage from 0 to 100. In particular,
21
+ missing weights must not silently imply free, infinite parallelism.
22
+ """
23
+ if "agent" not in (task.get("tags") or []):
24
+ return None
25
+ try:
26
+ weight = float(task["agent_weight"])
27
+ except (KeyError, TypeError, ValueError) as exc:
28
+ raise ValueError(
29
+ f"Task {task.get('id', task.get('uuid'))}: +agent requires a numeric agent_weight"
30
+ ) from exc
31
+ if not isfinite(weight) or not 0 <= weight <= 100:
32
+ raise ValueError(
33
+ f"Task {task.get('id', task.get('uuid'))}: agent_weight must be between 0 and 100"
34
+ )
35
+ return weight / 100
36
+
37
+
38
+ def _eligible(info, pending, date):
39
+ if any(dep in pending for dep in info["task"].get("depends", [])):
40
+ return False
41
+ wait = info["task"].get("wait")
42
+ if wait and date <= datetime.strptime(wait, "%Y%m%dT%H%M%SZ").date():
43
+ return False
44
+ return True
45
+
46
+
47
+ def _max_allocation(info, day_offset, block_hours):
48
+ return min(
49
+ float(info["remaining_hours"]),
50
+ float(info["task_time_map"][day_offset]),
51
+ block_hours,
52
+ )
53
+
54
+
55
+ def allocate_agent_block(
56
+ tasks_remaining, sorted_task_ids, day_offset, day_remaining_hours, date, anchor_uuid
57
+ ):
58
+ """Allocate one block anchored by an eligible agent task.
59
+
60
+ Returns (elapsed_hours, {uuid: allocated_task_hours}), and mutates the
61
+ selected task states in-place. None of the individual allocations is a
62
+ calendar sub-block. All agents run within the same elapsed block; human
63
+ work consumes interaction time left unused by the agents.
64
+
65
+ Tasks which depend on anything pending *at block start* cannot join this
66
+ same block, even if that prerequisite finishes within it.
67
+ """
68
+ anchor = tasks_remaining[anchor_uuid]
69
+ if get_agent_weight(anchor["task"]) is None:
70
+ return 0, {}
71
+ pending = set(tasks_remaining)
72
+ if not _eligible(anchor, pending, date):
73
+ return 0, {}
74
+ block_hours = min(
75
+ day_remaining_hours,
76
+ hours_to_decimal(float(anchor["task"].get("min_block", 2))),
77
+ )
78
+ if block_hours <= _EPSILON:
79
+ return 0, {}
80
+
81
+ allocations = {}
82
+ human_budget = block_hours
83
+ agent_order = [anchor_uuid] + [uuid for uuid in sorted_task_ids if uuid != anchor_uuid]
84
+ for uuid in agent_order:
85
+ info = tasks_remaining.get(uuid)
86
+ if info is None:
87
+ continue
88
+ weight = get_agent_weight(info["task"])
89
+ if weight is None or not _eligible(info, pending, date):
90
+ continue
91
+ agent_cap = min(
92
+ block_hours,
93
+ hours_to_decimal(float(info["task"].get("min_block", 2))),
94
+ )
95
+ duration = _max_allocation(info, day_offset, agent_cap)
96
+ if weight > 0:
97
+ duration = min(duration, max(human_budget, 0) / weight)
98
+ if duration <= _EPSILON:
99
+ continue
100
+ allocations[uuid] = duration
101
+ human_budget = max(0, human_budget - weight * duration)
102
+
103
+ # The anchor must have actual execution time; a zero-length block is invalid.
104
+ if anchor_uuid not in allocations:
105
+ return 0, {}
106
+
107
+ for uuid in sorted_task_ids:
108
+ info = tasks_remaining.get(uuid)
109
+ if info is None or get_agent_weight(info["task"]) is not None:
110
+ continue
111
+ if not _eligible(info, pending, date):
112
+ continue
113
+ # Deliberately allow sub-min_block *allocations*, but only in a mixed
114
+ # block. Standalone human scheduling stays in the legacy path.
115
+ # Honor task-specific chunk caps while allowing allocations *smaller*
116
+ # than that cap only within this mixed block.
117
+ human_cap = min(
118
+ human_budget, hours_to_decimal(float(info["task"].get("min_block", 2)))
119
+ )
120
+ duration = _max_allocation(info, day_offset, human_cap)
121
+ if duration <= _EPSILON:
122
+ continue
123
+ allocations[uuid] = duration
124
+ human_budget = max(0, human_budget - duration)
125
+ if human_budget <= _EPSILON:
126
+ break
127
+
128
+ # Charge only elapsed calendar time actually needed. Otherwise a short
129
+ # agent finishing alone would artificially consume a whole min_block.
130
+ # Interaction and human work must fit inside this elapsed interval, while
131
+ # all agent executions may overlap.
132
+ interaction_hours = sum(
133
+ duration * get_agent_weight(tasks_remaining[uuid]["task"])
134
+ if get_agent_weight(tasks_remaining[uuid]["task"]) is not None
135
+ else duration
136
+ for uuid, duration in allocations.items()
137
+ )
138
+ elapsed_hours = max(
139
+ interaction_hours,
140
+ *(
141
+ duration
142
+ for uuid, duration in allocations.items()
143
+ if get_agent_weight(tasks_remaining[uuid]["task"]) is not None
144
+ ),
145
+ )
146
+
147
+ date_str = date.isoformat()
148
+ for uuid, duration in allocations.items():
149
+ info = tasks_remaining[uuid]
150
+ if not info["scheduling"]:
151
+ info["started"] = True
152
+ info["remaining_hours"] = max(0, info["remaining_hours"] - duration)
153
+ # Cached time-map lists may be shared by different tasks; the mutable
154
+ # per-task remaining availability must not alias another task.
155
+ info["task_time_map"] = list(info["task_time_map"])
156
+ info["task_time_map"][day_offset] = max(
157
+ 0, info["task_time_map"][day_offset] - duration
158
+ )
159
+ info["scheduling"][date_str] = info["scheduling"].get(date_str, 0) + duration
160
+ return elapsed_hours, allocations
@@ -148,7 +148,25 @@ def hours_to_pdth(hours):
148
148
  def get_long_range_time_map(
149
149
  time_maps, time_map_names, days_ahead, calendars, verbose=False
150
150
  ):
151
- key = ",".join(time_map_names)
151
+ # The cache must distinguish both the horizon and the input configuration.
152
+ # Otherwise a previous schedule (possibly with a different time map or
153
+ # calendar) can silently give tasks stale daily availability.
154
+ key = (
155
+ tuple(time_map_names),
156
+ days_ahead,
157
+ datetime.today().date(),
158
+ json.dumps(
159
+ {name: time_maps[name] for name in time_map_names},
160
+ sort_keys=True,
161
+ default=str,
162
+ ),
163
+ json.dumps(
164
+ calendars,
165
+ sort_keys=True,
166
+ default=lambda value: value.isoformat()
167
+ if hasattr(value, "isoformat") else str(value),
168
+ ),
169
+ )
152
170
  if key in long_range_time_map:
153
171
  task_time_map = long_range_time_map[key]
154
172
  else:
@@ -63,6 +63,9 @@ def install():
63
63
  ("uda.min_block.type", "numeric"),
64
64
  ("uda.min_block.label", "Minimum Time Block"),
65
65
  ("uda.min_block.default", "2"),
66
+ ("uda.agent_weight.type", "numeric"),
67
+ ("uda.agent_weight.label", "Agent Weight (%)"),
68
+ ("uda.agent_weight.default", "25"),
66
69
  ("recurrence.confirmation", "no"),
67
70
  ]
68
71
 
@@ -120,7 +123,7 @@ def install():
120
123
  """
121
124
  _________________________________________________________________________________________
122
125
  Required configuration for taskcheck includes:
123
- - 3 UDA fields
126
+ - Taskwarrior UDAs, including `agent_weight` for +agent tasks
124
127
  - turning off confirmation for recurring tasks
125
128
 
126
129
  Do you want to continue? [y/N] """
@@ -6,6 +6,8 @@ from datetime import datetime, timedelta
6
6
 
7
7
  from rich.table import Table
8
8
 
9
+ from taskcheck.agent_blocks import allocate_agent_block, get_agent_weight
10
+
9
11
  from taskcheck.undo import create_undo_backup
10
12
  from taskcheck.common import (
11
13
  AVOID_STATUS,
@@ -330,11 +332,14 @@ def initialize_task_info(tasks, time_maps, days_ahead, urgency_coefficients, cal
330
332
  continue
331
333
  if "estimated" not in task or "time_map" not in task:
332
334
  continue
335
+ get_agent_weight(task) # Validate +agent tasks before scheduling.
333
336
  estimated_hours = pdth_to_hours(task["estimated"])
334
337
  time_map_names = task["time_map"].split(",")
335
338
  task_time_map, today_used_hours = get_long_range_time_map(
336
339
  time_maps, time_map_names, days_ahead, calendars
337
340
  )
341
+ # The cache returns a shared list. Mutations must be task-local.
342
+ task_time_map = list(task_time_map)
338
343
  task_uuid = task["uuid"]
339
344
  initial_urgency = float(task.get("urgency", 0))
340
345
  estimated_urgency = urgency_estimated(
@@ -415,6 +420,25 @@ def allocate_time_for_day(
415
420
  if verbose:
416
421
  print(f"Skipping task {info['task']['id']} due to wait date {wait}")
417
422
  continue
423
+ if get_agent_weight(info["task"]) is not None:
424
+ # Agent tasks overlap in elapsed time. Charge interactions and
425
+ # human work to the same block's human-time budget.
426
+ elapsed, allocations = allocate_agent_block(
427
+ tasks_remaining, sorted_task_ids, day_offset,
428
+ day_remaining_hours, date, uuid,
429
+ )
430
+ if elapsed > 0:
431
+ day_remaining_hours -= elapsed
432
+ allocated = True
433
+ if verbose:
434
+ print(f"Allocated mixed block ({elapsed:.2f}h): {allocations}")
435
+ for allocated_uuid in allocations:
436
+ allocated_info = tasks_remaining[allocated_uuid]
437
+ if (allocated_info["remaining_hours"] <= 0
438
+ or allocated_info["task_time_map"][day_offset] <= 0):
439
+ del tasks_remaining[allocated_uuid]
440
+ break
441
+ continue
418
442
  allocation = allocate_time_to_task(info, day_offset, day_remaining_hours)
419
443
  if allocation > 0:
420
444
  day_remaining_hours -= allocation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskcheck
3
- Version: 1.5.1
3
+ Version: 1.6.0
4
4
  Summary: Automatic scheduler for taskwarrior
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -13,8 +13,6 @@ Requires-Dist: random-unicode-emoji>=2.8
13
13
  Requires-Dist: requests>=2.32.3
14
14
  Requires-Dist: rich>=13.9.4
15
15
 
16
- ![immagine](https://github.com/user-attachments/assets/27b83bb1-7a50-4923-a453-0a958fbe11ed)
17
-
18
16
  A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
19
17
  It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
20
18
 
@@ -23,6 +21,7 @@ Use it if you want to:
23
21
  - fit tasks into real availability
24
22
  - be sure your long-time plan won't go overdue
25
23
  - keep Taskwarrior as the source of truth
24
+ - handle your time across multiple tasks carried on by LLMs
26
25
 
27
26
  > [!IMPORTANT]
28
27
  > This repo is actively maintained again
@@ -30,7 +29,7 @@ Use it if you want to:
30
29
  ## Features
31
30
 
32
31
  - ✨ Auto-schedule tasks from working hours + calendar blocks
33
- - 🧭 Support complex working-hour maps
32
+ - 🧠 Schedule agent-assisted tasks alongside your own, based on how much supervision they need
34
33
  - ⏱️ Consider urgency, due dates, and dependencies
35
34
  - 🧪 Persisted dry-run previews for reports and timelines
36
35
  - ↩️ Undo the latest schedule update
@@ -41,19 +40,16 @@ Use it if you want to:
41
40
  - 🎨 Report styling with emoji and extra attributes
42
41
  - 🗓️ Block time with iCal or Google Calendar, including all-day events
43
42
 
44
- ## Quick start
43
+ ![Dry-run schedule and report](docs/images/dry-run-report.png)
45
44
 
46
- 1. `pipx install taskcheck`
47
- 2. `taskcheck --install` ← setup only
48
- 3. add `estimated` + `time_map` UDAs
49
- 4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
50
- 5. `taskcheck --schedule`
51
- 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
45
+ ![Daily timeline](docs/images/timeline-day.png)
52
46
 
53
47
  ## How it works
54
48
 
55
- Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes back a schedule.
56
- It respects:
49
+ Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes
50
+ back a schedule. For tasks tagged `+agent`, it treats `estimated` as the agent's work time and
51
+ `agent_weight` as the percentage of that time needing your attention. It respects:
52
+
57
53
  - your working hours
58
54
  - task duration estimates
59
55
  - calendar events and vacations from iCal / Google Calendar
@@ -64,10 +60,21 @@ It updates Taskwarrior with:
64
60
  - `completion_date` → when work is expected to finish
65
61
  - a warning when a task will miss its due date
66
62
 
63
+ ## Quick start
64
+
65
+ 1. `pipx install taskcheck`
66
+ 2. `taskcheck --install` ← setup only: registers required Taskwarrior settings inyour taskwarrior config file and offers to create the
67
+ config file; it does not schedule tasks.
68
+ 3. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
69
+ 4. tune your tasks: add `estimated`, `time_map`, `agent_weight` UDAs and the `+agent` tag (`time_map` and `agent_weight` may use the default value defined in the Taskwarrior settings)
70
+ 5. `taskcheck --schedule`
71
+ 6. `taskcheck --undo` restores the scheduling fields changed by the latest run
72
+
67
73
  ### Required UDAs
68
74
 
69
75
  - `estimated`: expected duration in hours
70
76
  - `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
77
+ - `agent_weight`: required for `+agent` tasks; the percentage (0–100) of agent work time that needs your attention. It defaults to 25 when using `taskcheck --install`.
71
78
 
72
79
  Example:
73
80
 
@@ -77,6 +84,20 @@ monday = [[9, 12.30], [14, 17]]
77
84
  tuesday = [[9, 12.30], [14, 17]]
78
85
  ```
79
86
 
87
+ ### Agent tasks and mixed blocks
88
+
89
+ Mark a task `+agent` when a computer helper will do the work, and set `agent_weight` to show what
90
+ percentage of its work time needs your attention. For example, `10` means you spend about 10 minutes
91
+ interacting with the agent for each hour the agent works. Taskcheck can plan several agent jobs at
92
+ once, fill the time left with your own tasks, and respect waiting dates and dependencies.
93
+
94
+ ```bash
95
+ task add "Implement API" +agent estimated:5h agent_weight:10
96
+ task add "Review results" estimated:1h
97
+ ```
98
+
99
+ Run `taskcheck --install` to register the `agent_weight` Taskwarrior UDA.
100
+
80
101
  ### Reports and timelines
81
102
 
82
103
  - `taskcheck -r today` → tasks planned for today
@@ -84,9 +105,15 @@ tuesday = [[9, 12.30], [14, 17]]
84
105
  - `taskcheck --timeline 1w` → automatic day/week/month timeline for the next week
85
106
  - `taskcheck --timeline eom --zoom week` → weekly timeline through month end
86
107
 
87
- Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report today --dry-run` or `taskcheck --timeline 1w --dry-run`.
108
+ Create a preview with `taskcheck --schedule --dry-run`. It does not update Taskwarrior; instead, it
109
+ saves the generated schedule to the Taskwarrior data directory. Reuse it with `taskcheck --report
110
+ today --dry-run` or `taskcheck --timeline 1w --dry-run`.
88
111
 
89
- Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most detailed view that fits the terminal width is selected: day, then week, then month. The track uses `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules separate days, weeks, and months. Project colors are deterministic and adapt to light or dark terminals through OSC 11, with config and environment fallbacks.
112
+ Timeline rows follow dotted project levels such as `work.client`. Without `--zoom`, the most
113
+ detailed view that fits the terminal width is selected: day, then week, then month. The track uses
114
+ `▶` for a start, `━` for work, `◀` for an end, and `◆` for a deadline. Thin, heavy, and double rules
115
+ separate days, weeks, and months. Project colors are deterministic and adapt to light or dark
116
+ terminals through OSC 11, with config and environment fallbacks.
90
117
 
91
118
  ## Reference
92
119
 
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  taskcheck/__main__.py
5
+ taskcheck/agent_blocks.py
5
6
  taskcheck/common.py
6
7
  taskcheck/dry_run.py
7
8
  taskcheck/google_calendar.py
@@ -18,6 +19,8 @@ taskcheck.egg-info/dependency_links.txt
18
19
  taskcheck.egg-info/entry_points.txt
19
20
  taskcheck.egg-info/requires.txt
20
21
  taskcheck.egg-info/top_level.txt
22
+ tests/test_agent_blocks.py
23
+ tests/test_agent_scheduler_integration.py
21
24
  tests/test_common.py
22
25
  tests/test_google_calendar.py
23
26
  tests/test_ical.py
@@ -0,0 +1,196 @@
1
+ from datetime import date, timedelta
2
+
3
+ import pytest
4
+
5
+ from taskcheck.agent_blocks import allocate_agent_block, get_agent_weight
6
+
7
+ TODAY = date.today()
8
+
9
+
10
+ def task(name, *, agent=False, weight=None, hours=2, urgency=10, depends=(), wait=None, daily=8):
11
+ record = {"uuid": name, "id": name, "estimated": "PT2H", "min_block": 2}
12
+ if agent:
13
+ record["tags"] = ["agent"]
14
+ if weight is not None:
15
+ record["agent_weight"] = weight
16
+ if depends:
17
+ record["depends"] = list(depends)
18
+ if wait:
19
+ record["wait"] = wait
20
+ return {"task": record, "remaining_hours": hours, "task_time_map": [daily],
21
+ "scheduling": {}, "started": False, "urgency": urgency}
22
+
23
+
24
+ def run(items, order=None, block=2):
25
+ ordered = order or sorted(items, key=lambda name: -items[name]["urgency"])
26
+ return allocate_agent_block(items, ordered, 0, block, TODAY, ordered[0])
27
+
28
+
29
+ def test_mixed_two_agents_and_short_human_tasks():
30
+ items = {
31
+ "a": task("a", agent=True, weight=25),
32
+ "b": task("b", agent=True, weight=35),
33
+ "h1": task("h1", hours=.5),
34
+ "h2": task("h2", hours=.5),
35
+ }
36
+ elapsed, selected = run(items, ["a", "b", "h1", "h2"])
37
+ assert elapsed == 2
38
+ assert selected == pytest.approx({"a": 2, "b": 2, "h1": .5, "h2": .3})
39
+ assert items["h1"]["remaining_hours"] == pytest.approx(0)
40
+ assert items["h2"]["remaining_hours"] == pytest.approx(.2)
41
+ assert all(set(info["scheduling"]) == {TODAY.isoformat()} for info in items.values())
42
+
43
+
44
+ def test_short_agent_duration_consumes_only_actual_interaction():
45
+ items = {"a": task("a", agent=True, weight=25, hours=1),
46
+ "h": task("h", hours=2)}
47
+ elapsed, allocation = run(items, ["a", "h"])
48
+ assert elapsed == 2
49
+ assert allocation == pytest.approx({"a": 1, "h": 1.75})
50
+
51
+
52
+ def test_full_weight_agent_excludes_human():
53
+ items = {"a": task("a", agent=True, weight=100),
54
+ "h": task("h", hours=2)}
55
+ _, selected = run(items, ["a", "h"])
56
+ assert selected == {"a": 2}
57
+ assert items["h"]["remaining_hours"] == 2
58
+
59
+
60
+ def test_dependencies_cannot_execute_with_agent_in_same_block():
61
+ items = {"a": task("a", agent=True, weight=25),
62
+ "h": task("h", hours=1, depends=["a"]),
63
+ "other": task("other", hours=1)}
64
+ _, selected = run(items, ["a", "h", "other"])
65
+ assert "h" not in selected
66
+ assert selected["other"] == 1
67
+
68
+
69
+ def test_waiting_agent_excluded_even_when_urgent():
70
+ tomorrow = (TODAY + timedelta(days=1)).strftime("%Y%m%dT%H%M%SZ")
71
+ items = {"a": task("a", agent=True, weight=25),
72
+ "b": task("b", agent=True, weight=25, wait=tomorrow),
73
+ "h": task("h", hours=2)}
74
+ _, selected = run(items, ["a", "b", "h"])
75
+ assert selected == pytest.approx({"a": 2, "h": 1.5})
76
+
77
+
78
+ def test_daily_availability_limits_agent_and_human():
79
+ items = {"a": task("a", agent=True, weight=50, daily=.5),
80
+ "h": task("h", hours=2, daily=.75)}
81
+ elapsed, selected = run(items, ["a", "h"])
82
+ assert elapsed == pytest.approx(1)
83
+ assert selected == pytest.approx({"a": .5, "h": .75})
84
+
85
+
86
+ def test_agent_capacity_limits_multiple_agents():
87
+ items = {"a": task("a", agent=True, weight=80),
88
+ "b": task("b", agent=True, weight=40)}
89
+ _, selected = run(items, ["a", "b"])
90
+ assert selected == pytest.approx({"a": 2, "b": 1})
91
+
92
+
93
+ @pytest.mark.parametrize("bad", [None, -1, 101, "bad", float("nan")])
94
+ def test_invalid_weight_is_rejected(bad):
95
+ t = task("a", agent=True, weight=bad)["task"]
96
+ with pytest.raises(ValueError, match="agent_weight"):
97
+ get_agent_weight(t)
98
+
99
+
100
+ def test_non_agent_does_not_require_weight():
101
+ assert get_agent_weight(task("h")["task"]) is None
102
+
103
+
104
+ def test_no_real_subblocks_appear_in_scheduling():
105
+ items = {"a": task("a", agent=True, weight=25), "h": task("h", hours=.5)}
106
+ _, _ = run(items, ["a", "h"])
107
+ assert items["a"]["scheduling"] == {TODAY.isoformat(): 2}
108
+ assert items["h"]["scheduling"] == {TODAY.isoformat(): .5}
109
+
110
+
111
+ def test_short_lone_agent_does_not_reserve_unused_calendar_time():
112
+ items = {"a": task("a", agent=True, weight=25, hours=1)}
113
+ elapsed, selected = run(items, ["a"])
114
+ assert selected == {"a": 1}
115
+ assert elapsed == pytest.approx(1)
116
+
117
+
118
+ def test_mixed_block_elapsed_reflects_agents_and_supervision_time():
119
+ items = {"a": task("a", agent=True, weight=25, hours=1),
120
+ "h": task("h", hours=1)}
121
+ elapsed, selected = run(items, ["a", "h"])
122
+ assert selected == pytest.approx({"a": 1, "h": 1})
123
+ assert elapsed == pytest.approx(1.25)
124
+
125
+
126
+ def test_one_minute_human_task_is_valid_inside_mixed_block():
127
+ # min_block is a bound on *outer* chunks; it does not apply to human
128
+ # allocations inside a mixed block.
129
+ items = {"a": task("a", agent=True, weight=10, hours=2),
130
+ "h": task("h", hours=1 / 60)}
131
+ elapsed, selected = run(items, ["a", "h"])
132
+ assert elapsed == pytest.approx(2)
133
+ assert selected["h"] == pytest.approx(1 / 60)
134
+ assert items["h"]["remaining_hours"] == pytest.approx(0)
135
+
136
+
137
+ def test_one_minute_agent_can_anchor_mixed_block():
138
+ items = {"a": task("a", agent=True, weight=10, hours=1 / 60),
139
+ "h": task("h", hours=.5)}
140
+ elapsed, selected = run(items, ["a", "h"])
141
+ assert selected == pytest.approx({"a": 1 / 60, "h": .5})
142
+ assert elapsed == pytest.approx(.5 + 1 / 600)
143
+
144
+
145
+ def test_cached_time_map_is_not_mutated_across_tasks():
146
+ # get_long_range_time_map caches its list by name in the upstream module.
147
+ # An independent per-task daily cap is required: a task's execution must
148
+ # not eat another task's cap just because they share the same profile.
149
+ items = {"a": task("a", agent=True, weight=10, hours=1),
150
+ "h": task("h", hours=.5)}
151
+ original_shared_map = [2.0]
152
+ items["a"]["task_time_map"] = original_shared_map
153
+ items["h"]["task_time_map"] = original_shared_map
154
+ elapsed, selected = run(items, ["a", "h"])
155
+ assert selected == pytest.approx({"a": 1, "h": .5})
156
+ assert elapsed == pytest.approx(1)
157
+ assert items["a"]["task_time_map"] == pytest.approx([1])
158
+ assert items["h"]["task_time_map"] == pytest.approx([1.5])
159
+ assert original_shared_map == [2.0]
160
+
161
+
162
+ def test_many_mixed_blocks_respect_elapsed_and_human_budget():
163
+ import random
164
+ rng = random.Random(765421)
165
+ for _ in range(500):
166
+ weights = [rng.uniform(0, 100) for _ in range(rng.randint(1, 6))]
167
+ items = {
168
+ f"a{i}": task(f"a{i}", agent=True, weight=w,
169
+ hours=rng.uniform(.01, 4), daily=rng.uniform(.1, 3))
170
+ for i, w in enumerate(weights)
171
+ }
172
+ for j in range(rng.randint(0, 5)):
173
+ items[f"h{j}"] = task(f"h{j}", hours=rng.uniform(.01, 4),
174
+ daily=rng.uniform(.1, 3))
175
+ originals = {name: data['remaining_hours'] for name, data in items.items()}
176
+ elapsed, selected = run(items, list(items))
177
+ assert 0 < elapsed <= 2 + 1e-8
178
+ interaction = sum(selected.get(name, 0) * (get_agent_weight(info['task']) or 0)
179
+ if 'agent' in info['task'].get('tags', [])
180
+ else selected.get(name, 0)
181
+ for name, info in items.items())
182
+ assert interaction <= elapsed + 1e-8
183
+ for name, allocated in selected.items():
184
+ assert allocated <= originals[name] + 1e-8
185
+ assert items[name]['remaining_hours'] >= -1e-8
186
+ if get_agent_weight(items[name]['task']) is not None:
187
+ assert allocated <= elapsed + 1e-8
188
+
189
+
190
+ def test_human_custom_chunk_limit_is_respected_inside_mixed_block():
191
+ items = {"a": task("a", agent=True, weight=10, hours=2),
192
+ "h": task("h", hours=3)}
193
+ items['h']['task']['min_block'] = 0.3 # Original hh.mm convention: 30 minutes.
194
+ elapsed, selected = run(items, ['a', 'h'])
195
+ assert elapsed == pytest.approx(2)
196
+ assert selected['h'] == pytest.approx(.5)
@@ -0,0 +1,153 @@
1
+ """Integration coverage against taskcheck.parallel's day-allocation loop."""
2
+ from datetime import datetime, timedelta
3
+
4
+ import pytest
5
+
6
+ from taskcheck.parallel import UrgencyCoefficients, allocate_time_for_day
7
+
8
+
9
+ COEFFS = UrgencyCoefficients({"PT2H": 0}, False, 0, 365, 0, 0)
10
+
11
+
12
+ def info(name, *, hours=2, urgency=10, agent=False, weight=None, depends=()):
13
+ t = {"uuid": name, "id": name, "description": name,
14
+ "estimated": "PT2H", "min_block": 2}
15
+ if agent:
16
+ t["tags"] = ["agent"]
17
+ t["agent_weight"] = weight
18
+ if depends:
19
+ t["depends"] = list(depends)
20
+ return {"task": t, "remaining_hours": hours,
21
+ "task_time_map": [2.0, 2.0], "today_used_hours": 0,
22
+ "scheduling": {}, "urgency": urgency,
23
+ "estimated_urgency": 0, "age_urgency": 0, "due_urgency": 0,
24
+ "started": False}
25
+
26
+
27
+ def schedule(tasks, monkeypatch):
28
+ monkeypatch.setattr('taskcheck.parallel.recompute_urgencies', lambda *args, **kwargs: None)
29
+ allocate_time_for_day(tasks, 1, COEFFS, False, 1)
30
+ return (datetime.today().date() + timedelta(days=1)).isoformat()
31
+
32
+
33
+ def test_mixed_allocation_inside_one_calendar_block(monkeypatch):
34
+ tasks = {"a": info("a", agent=True, weight=25),
35
+ "b": info("b", agent=True, weight=35, urgency=9),
36
+ "h": info("h", hours=.8, urgency=8)}
37
+ day = schedule(tasks, monkeypatch)
38
+ assert tasks["a"]["scheduling"] == {day: 2}
39
+ assert tasks["b"]["scheduling"] == {day: 2}
40
+ assert tasks["h"]["scheduling"] == pytest.approx({day: .8})
41
+
42
+
43
+ def test_more_urgent_human_keeps_exclusive_block(monkeypatch):
44
+ tasks = {"h": info("h", urgency=20),
45
+ "a": info("a", agent=True, weight=25, urgency=10)}
46
+ day = schedule(tasks, monkeypatch)
47
+ assert tasks["h"]["scheduling"] == {day: 2}
48
+ assert tasks["a"]["scheduling"] == {}
49
+
50
+
51
+ def test_dependencies_wait_for_next_block(monkeypatch):
52
+ tasks = {"a": info("a", agent=True, weight=25, urgency=20),
53
+ "dependent": info("dependent", hours=.5, urgency=18, depends=("a",)),
54
+ "h": info("h", hours=.5, urgency=15)}
55
+ day = schedule(tasks, monkeypatch)
56
+ assert tasks["dependent"]["scheduling"] == {}
57
+ assert tasks["h"]["scheduling"] == {day: .5}
58
+
59
+
60
+ def test_five_hour_agent_ten_percent_occupies_two_and_half_blocks(monkeypatch):
61
+ # estimated:5h = execution time, NOT supervision time.
62
+ monkeypatch.setattr('taskcheck.parallel.recompute_urgencies', lambda *args, **kwargs: None)
63
+ agent = info('a', hours=5, agent=True, weight=10)
64
+ agent['task']['estimated'] = 'PT5H'
65
+ agent['task_time_map'] = [2.0, 2.0, 2.0, 2.0]
66
+ allocated_days = []
67
+ for offset in range(1, 4):
68
+ allocate_time_for_day({'a': agent}, offset, COEFFS, False, 1)
69
+ day = (datetime.today().date() + timedelta(days=offset)).isoformat()
70
+ allocated_days.append(agent['scheduling'].get(day, 0))
71
+ assert allocated_days == pytest.approx([2, 2, 1])
72
+ assert sum(allocated_days) == pytest.approx(5)
73
+ assert sum(allocated_days) * .10 == pytest.approx(.5)
74
+ assert agent['remaining_hours'] == pytest.approx(0)
75
+
76
+
77
+ def test_five_hour_agent_and_four_hour_human_fit_in_five_calendar_hours(monkeypatch):
78
+ """Regression: estimates are agent wall-time, NOT supervision time."""
79
+ monkeypatch.setattr('taskcheck.parallel.recompute_urgencies', lambda *args, **kwargs: None)
80
+ a = info('a', hours=5, agent=True, weight=10, urgency=20)
81
+ a['task']['estimated'] = 'PT5H'
82
+ h = info('h', hours=4, urgency=10)
83
+ for item in (a, h):
84
+ item['task_time_map'] = [0, 5]
85
+ task_info = {'a': a, 'h': h}
86
+ allocate_time_for_day(task_info, 1, COEFFS, False, 1)
87
+ day = (datetime.today().date() + timedelta(days=1)).isoformat()
88
+ assert a['scheduling'][day] == pytest.approx(5)
89
+ assert h['scheduling'][day] == pytest.approx(4)
90
+ assert a['remaining_hours'] == pytest.approx(0)
91
+ assert h['remaining_hours'] == pytest.approx(0)
92
+ # 5h elapsed, with 5h*10%=0.5h supervision + 4h independent work.
93
+ assert a['scheduling'][day] * .10 + h['scheduling'][day] == pytest.approx(4.5)
94
+
95
+
96
+ def test_initialize_task_info_does_not_share_cached_availability(monkeypatch):
97
+ from taskcheck.parallel import initialize_task_info
98
+ shared = [3., 3.]
99
+ monkeypatch.setattr('taskcheck.parallel.get_long_range_time_map',
100
+ lambda *args: (shared, 0))
101
+ tasks = [
102
+ {'id': 1, 'uuid': 'a', 'estimated': 'PT2H', 'time_map': 'work',
103
+ 'tags': ['agent'], 'agent_weight': 25},
104
+ {'id': 2, 'uuid': 'h', 'estimated': 'PT2H', 'time_map': 'work'},
105
+ ]
106
+ states = initialize_task_info(tasks, {}, 2, COEFFS, [])
107
+ assert states['a']['task_time_map'] is not states['h']['task_time_map']
108
+ assert states['a']['task_time_map'] is not shared
109
+ states['a']['task_time_map'][0] -= 2
110
+ assert states['h']['task_time_map'] == [3., 3.]
111
+ assert shared == [3., 3.]
112
+
113
+
114
+ def test_initialize_task_info_rejects_missing_agent_weight(monkeypatch):
115
+ from taskcheck.parallel import initialize_task_info
116
+ tasks = [{'id': 1, 'uuid': 'a', 'estimated': 'PT2H',
117
+ 'time_map': 'work', 'tags': ['agent']}]
118
+ with pytest.raises(ValueError, match='agent_weight'):
119
+ initialize_task_info(tasks, {}, 2, COEFFS, [])
120
+
121
+
122
+ def test_availability_cache_is_scoped_to_horizon_and_time_map():
123
+ """A new schedule must not inherit a stale cached horizon or work profile."""
124
+ from taskcheck.common import get_long_range_time_map, long_range_time_map
125
+ long_range_time_map.clear()
126
+ work = {day: [[9, 17]] for day in
127
+ ('monday', 'tuesday', 'wednesday', 'thursday', 'friday',
128
+ 'saturday', 'sunday')}
129
+ try:
130
+ short, _ = get_long_range_time_map({'work': work}, ['work'], 2, [])
131
+ long, _ = get_long_range_time_map({'work': work}, ['work'], 3, [])
132
+ assert len(short) == 2
133
+ assert len(long) == 3
134
+ shorter_hours = {day: [[9, 12]] for day in work}
135
+ changed, _ = get_long_range_time_map(
136
+ {'work': shorter_hours}, ['work'], 3, [])
137
+ assert changed == [3., 3., 3.]
138
+ finally:
139
+ long_range_time_map.clear()
140
+
141
+
142
+ def test_install_registers_agent_weight_uda(monkeypatch):
143
+ from taskcheck import install as install_module
144
+ from unittest.mock import Mock
145
+ apply = Mock()
146
+ monkeypatch.setattr(install_module, 'apply_config', apply)
147
+ answers = iter(('yes', 'no', 'no'))
148
+ monkeypatch.setattr('builtins.input', lambda *args: next(answers))
149
+ install_module.install()
150
+ applied = [call.args for call in apply.call_args_list]
151
+ assert ('uda.agent_weight.type', 'numeric') in applied
152
+ assert ('uda.agent_weight.label', 'Agent Weight (%)') in applied
153
+ assert ('uda.agent_weight.default', '25') in applied
@@ -101,6 +101,9 @@ class TestArgumentParsing:
101
101
  args = arg_parser.parse_args(["--dry-run"])
102
102
  assert args.dry_run is True
103
103
 
104
+ args = arg_parser.parse_args(["-d"])
105
+ assert args.dry_run is True
106
+
104
107
  args = arg_parser.parse_args([])
105
108
  assert args.dry_run is False
106
109
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes