pikuri-tasks 0.0.4
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.
- checksums.yaml +7 -0
- data/README.md +79 -0
- data/lib/pikuri/tasks/completed.rb +55 -0
- data/lib/pikuri/tasks/create.rb +83 -0
- data/lib/pikuri/tasks/delete.rb +59 -0
- data/lib/pikuri/tasks/extension.rb +103 -0
- data/lib/pikuri/tasks/in_progress.rb +56 -0
- data/lib/pikuri/tasks/list.rb +148 -0
- data/lib/pikuri-tasks.rb +24 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 532d85628fc253786ae595ad4a26e47ecf6c7b0e116f14d41d211a32ebaeb084
|
|
4
|
+
data.tar.gz: 840465bdd7eed7f31a28b3f59ee2b1784f27cf07e2819323d66e60c1f4e4a99e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1ea43022b727733934abb762cf62d604c3fabde847ca7e10c0fdbd2cb53000f41c4fc83b822e023e41555eb9b610cbfd5a249decf4352bda53c447f0ea5cd050
|
|
7
|
+
data.tar.gz: c6f8e656609113785b062f197e79b87b5753c5cc6290b05f5442b6b1652ede03530f9ffd39f39b14ae542b64ddaf469c82e7d5cd7383999d2a3531d346da1e32
|
data/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# pikuri-tasks
|
|
2
|
+
|
|
3
|
+
In-memory task list + four LLM-facing tools for the
|
|
4
|
+
[pikuri](https://codeberg.org/mvysny/pikuri) AI-assistant toolkit.
|
|
5
|
+
|
|
6
|
+
Provides:
|
|
7
|
+
- `Pikuri::Tasks::List` — per-Agent in-memory list of
|
|
8
|
+
`(content, status)` items. Status is one of `pending`,
|
|
9
|
+
`in_progress`, `completed`. Nothing is written to disk.
|
|
10
|
+
- Four tool classes, all sharing one `List` instance:
|
|
11
|
+
- `Pikuri::Tasks::Create` (`task_create`) — mass-create pending
|
|
12
|
+
items from a newline-separated `items` string. Atomic: if any
|
|
13
|
+
line is a duplicate (within the batch or already on the list),
|
|
14
|
+
nothing is added.
|
|
15
|
+
- `Pikuri::Tasks::InProgress` (`task_in_progress`) — mark an item
|
|
16
|
+
as `in_progress` by content.
|
|
17
|
+
- `Pikuri::Tasks::Completed` (`task_completed`) — mark an item as
|
|
18
|
+
`completed` by content.
|
|
19
|
+
- `Pikuri::Tasks::Delete` (`task_delete`) — remove an item by
|
|
20
|
+
content.
|
|
21
|
+
- `Pikuri::Tasks::Extension` — wires the four tools + a brief
|
|
22
|
+
`<tasks_usage>` workflow snippet into a `Pikuri::Agent` via the
|
|
23
|
+
`c.add_extension(...)` block API.
|
|
24
|
+
|
|
25
|
+
Two shape choices worth flagging:
|
|
26
|
+
- **Status is baked into the tool name** (no `status:` parameter
|
|
27
|
+
with an enum). Removes the `"in-progress"` vs `"in_progress"`
|
|
28
|
+
vs `"inprogress"` typo failure mode on smaller models.
|
|
29
|
+
- **Content doubles as identifier** across the three update tools
|
|
30
|
+
(no item IDs to bookkeep). Duplicates are rejected on
|
|
31
|
+
`task_create` so the identifier stays unique.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Gemfile
|
|
37
|
+
gem 'pikuri-tasks'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require 'pikuri-core'
|
|
44
|
+
require 'pikuri-tasks'
|
|
45
|
+
|
|
46
|
+
agent = Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
47
|
+
c.add_extension(Pikuri::Tasks::Extension.new)
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The extension's `configure` constructs a fresh `Tasks::List`,
|
|
52
|
+
registers all four task tools against it (so they mutate the same
|
|
53
|
+
instance), and appends a short `<tasks_usage>` snippet to the system
|
|
54
|
+
prompt explaining the workflow (create at the start, exactly one
|
|
55
|
+
`in_progress` at a time, `completed` only after verification).
|
|
56
|
+
|
|
57
|
+
Every mutation returns the full rendered list as its observation,
|
|
58
|
+
so the LLM always sees fresh state without a separate read tool:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
<tasks>
|
|
62
|
+
- [pending] Add dark mode toggle
|
|
63
|
+
- [in_progress] Write unit tests
|
|
64
|
+
- [completed] Update README
|
|
65
|
+
</tasks>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Empty renders as `<tasks>(empty)</tasks>` so the LLM gets an
|
|
69
|
+
unambiguous "the call worked and the list is now empty" signal.
|
|
70
|
+
|
|
71
|
+
Sub-agents do **not** inherit extensions — a sub-agent spawned by
|
|
72
|
+
the `agent` tool gets a fresh persona and no task list. The
|
|
73
|
+
parent's plan stays private to the parent.
|
|
74
|
+
|
|
75
|
+
## Further reading
|
|
76
|
+
|
|
77
|
+
- **API reference:** browse the YARD docs at
|
|
78
|
+
<https://rubydoc.info/gems/pikuri-tasks> (once published), or
|
|
79
|
+
run `bundle exec yard` in this directory for a local copy.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Tasks
|
|
5
|
+
# The +task_completed+ tool: mark the item whose +content+ exactly
|
|
6
|
+
# matches as +completed+. Same shape and rationale as
|
|
7
|
+
# {InProgress} — the status is baked into the tool name to remove
|
|
8
|
+
# the enum-typo failure mode small models hit on
|
|
9
|
+
# +"completed"+ / +"complete"+ / +"done"+.
|
|
10
|
+
#
|
|
11
|
+
# Returns the rendered current list via {List#render} on success,
|
|
12
|
+
# or +"Error: no such task: '<content>'"+ when the content does
|
|
13
|
+
# not match.
|
|
14
|
+
class Completed < Pikuri::Tool
|
|
15
|
+
# @return [String]
|
|
16
|
+
DESCRIPTION = <<~DESC
|
|
17
|
+
Mark a task as `completed` once the work — including any required verification — is actually done.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
- Pass the exact `content` string the task was created with.
|
|
21
|
+
- Do NOT mark `completed` based on intent; mark it only after the underlying work is verified.
|
|
22
|
+
- If the work is partially done or blocked, leave the task `in_progress` and add a follow-up via `task_create`.
|
|
23
|
+
- On `Error: no such task: ...` the call did nothing — read the returned list in any subsequent tool's output to pick the right name.
|
|
24
|
+
- On success the full current list is returned for you to read back.
|
|
25
|
+
DESC
|
|
26
|
+
|
|
27
|
+
# @param list [List]
|
|
28
|
+
# @return [Completed]
|
|
29
|
+
def initialize(list:)
|
|
30
|
+
super(
|
|
31
|
+
name: 'task_completed',
|
|
32
|
+
description: DESCRIPTION,
|
|
33
|
+
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
34
|
+
p.required_string :content,
|
|
35
|
+
'Exact content of the existing task to mark as ' \
|
|
36
|
+
'completed, e.g. "Add dark mode toggle".'
|
|
37
|
+
},
|
|
38
|
+
execute: lambda { |content:|
|
|
39
|
+
Completed.execute(list: list, content: content)
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param list [List]
|
|
45
|
+
# @param content [String]
|
|
46
|
+
# @return [String]
|
|
47
|
+
def self.execute(list:, content:)
|
|
48
|
+
list.set_status(content: content, status: 'completed')
|
|
49
|
+
list.render
|
|
50
|
+
rescue ItemNotFound
|
|
51
|
+
"Error: no such task: '#{content}'"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Tasks
|
|
5
|
+
# The +task_create+ tool: mass-create pending items in a single
|
|
6
|
+
# call from a newline-separated +items+ string. Why
|
|
7
|
+
# newline-separated rather than a JSON array: it stays within
|
|
8
|
+
# pikuri's scalar-only +Tool::Parameters+ DSL (no array support
|
|
9
|
+
# to extend), and a smaller model never has to balance brackets
|
|
10
|
+
# or escape quotes — fewer formatting failure modes on the burst
|
|
11
|
+
# of items that opens most multi-step work.
|
|
12
|
+
#
|
|
13
|
+
# Each line is right- and left-stripped; blank lines are skipped.
|
|
14
|
+
# If any input is a duplicate (within the batch, or already in
|
|
15
|
+
# the list), the whole call aborts with an +"Error: ..."+ string
|
|
16
|
+
# and nothing is added — the LLM resends a corrected batch on the
|
|
17
|
+
# next turn. Atomic semantics keep the list in a coherent state
|
|
18
|
+
# the LLM doesn't have to reconcile.
|
|
19
|
+
#
|
|
20
|
+
# On success returns the rendered current list via {List#render},
|
|
21
|
+
# so the LLM always sees fresh state without a separate read tool.
|
|
22
|
+
class Create < Pikuri::Tool
|
|
23
|
+
# @return [String] static description shown to the LLM,
|
|
24
|
+
# opencode-shape (summary + +Usage:+ bullets).
|
|
25
|
+
DESCRIPTION = <<~DESC
|
|
26
|
+
Create one or more new task items in a single call. All items start as `pending`.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
- Use at the start of a multi-step task to capture the plan.
|
|
30
|
+
- `items` is a single newline-separated string — one task per line. Blank lines are ignored.
|
|
31
|
+
- Duplicate content (within the batch or already on the list) aborts the whole call with `Error: ...` and adds nothing — resend a corrected batch.
|
|
32
|
+
- Empty input is rejected the same way.
|
|
33
|
+
- On success the full current list is returned for you to read back.
|
|
34
|
+
DESC
|
|
35
|
+
|
|
36
|
+
# @param list [List] the shared per-Agent list, captured by
|
|
37
|
+
# closure so every tool in the {Extension}'s set mutates the
|
|
38
|
+
# same instance.
|
|
39
|
+
# @return [Create]
|
|
40
|
+
def initialize(list:)
|
|
41
|
+
super(
|
|
42
|
+
name: 'task_create',
|
|
43
|
+
description: DESCRIPTION,
|
|
44
|
+
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
45
|
+
p.required_string :items,
|
|
46
|
+
'Newline-separated list of task contents, e.g. ' \
|
|
47
|
+
'"Add dark mode toggle\nWrite unit tests\nUpdate README". ' \
|
|
48
|
+
'Blank lines are ignored.'
|
|
49
|
+
},
|
|
50
|
+
execute: lambda { |items:|
|
|
51
|
+
Create.execute(list: list, items: items)
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Validate and apply the batch. Public so specs can drive it
|
|
57
|
+
# without constructing a tool instance.
|
|
58
|
+
#
|
|
59
|
+
# @param list [List]
|
|
60
|
+
# @param items [String] raw +items+ argument from the LLM.
|
|
61
|
+
# @return [String] either {List#render} on success or an
|
|
62
|
+
# +"Error: ..."+ string the LLM can react to.
|
|
63
|
+
def self.execute(list:, items:)
|
|
64
|
+
cleaned = items.lines.map(&:strip).reject(&:empty?)
|
|
65
|
+
return 'Error: task_create requires at least one non-blank item' if cleaned.empty?
|
|
66
|
+
|
|
67
|
+
seen_in_batch = {}
|
|
68
|
+
cleaned.each do |c|
|
|
69
|
+
return "Error: duplicate item in batch: '#{c}'" if seen_in_batch[c]
|
|
70
|
+
|
|
71
|
+
seen_in_batch[c] = true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
existing = list.items.map(&:content)
|
|
75
|
+
clash = cleaned.find { |c| existing.include?(c) }
|
|
76
|
+
return "Error: task already exists: '#{clash}'" if clash
|
|
77
|
+
|
|
78
|
+
cleaned.each { |c| list.add(c) }
|
|
79
|
+
list.render
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Tasks
|
|
5
|
+
# The +task_delete+ tool: remove the item whose +content+ exactly
|
|
6
|
+
# matches from the list. Used to drop items that turned out to be
|
|
7
|
+
# unnecessary, were created in error, or have been superseded —
|
|
8
|
+
# rather than leaving them sitting in +pending+ as visual noise.
|
|
9
|
+
#
|
|
10
|
+
# Distinct from {Completed}: +completed+ means the work was
|
|
11
|
+
# actually done; +delete+ means the task should never have been
|
|
12
|
+
# there. The list itself draws no such distinction once an item
|
|
13
|
+
# is gone, but the LLM picks the right verb because the tool
|
|
14
|
+
# names make the intent clear.
|
|
15
|
+
#
|
|
16
|
+
# Returns the rendered current list via {List#render} on success,
|
|
17
|
+
# or +"Error: no such task: '<content>'"+ when the content does
|
|
18
|
+
# not match.
|
|
19
|
+
class Delete < Pikuri::Tool
|
|
20
|
+
# @return [String]
|
|
21
|
+
DESCRIPTION = <<~DESC
|
|
22
|
+
Remove a task from the list. Use this for items that turn out not to be needed, were created in error, or have been superseded.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
- Pass the exact `content` string the task was created with.
|
|
26
|
+
- Use `task_completed` (not this) when the work was actually done.
|
|
27
|
+
- On `Error: no such task: ...` the call did nothing — read the returned list in any subsequent tool's output to pick the right name.
|
|
28
|
+
- On success the full current list is returned for you to read back.
|
|
29
|
+
DESC
|
|
30
|
+
|
|
31
|
+
# @param list [List]
|
|
32
|
+
# @return [Delete]
|
|
33
|
+
def initialize(list:)
|
|
34
|
+
super(
|
|
35
|
+
name: 'task_delete',
|
|
36
|
+
description: DESCRIPTION,
|
|
37
|
+
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
38
|
+
p.required_string :content,
|
|
39
|
+
'Exact content of the existing task to remove, ' \
|
|
40
|
+
'e.g. "Add dark mode toggle".'
|
|
41
|
+
},
|
|
42
|
+
execute: lambda { |content:|
|
|
43
|
+
Delete.execute(list: list, content: content)
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param list [List]
|
|
49
|
+
# @param content [String]
|
|
50
|
+
# @return [String]
|
|
51
|
+
def self.execute(list:, content:)
|
|
52
|
+
list.delete(content)
|
|
53
|
+
list.render
|
|
54
|
+
rescue ItemNotFound
|
|
55
|
+
"Error: no such task: '#{content}'"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
# Namespace for the in-memory task-list feature. Holds the
|
|
5
|
+
# {List} value type, the four task tool classes ({Create},
|
|
6
|
+
# {InProgress}, {Completed}, {Delete}), and the {Extension} that
|
|
7
|
+
# wires them into an {Pikuri::Agent}.
|
|
8
|
+
module Tasks
|
|
9
|
+
# An {Pikuri::Agent::Extension} that auto-wires an in-memory
|
|
10
|
+
# task list onto an agent: constructs a fresh {List}, registers
|
|
11
|
+
# the four task tool classes against it, and appends a brief
|
|
12
|
+
# workflow snippet to the system prompt.
|
|
13
|
+
#
|
|
14
|
+
# == Usage
|
|
15
|
+
#
|
|
16
|
+
# Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
17
|
+
# c.add_extension Pikuri::Tasks::Extension.new
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# The list is per-Agent and in-memory. It is dropped when the
|
|
21
|
+
# agent is garbage-collected — nothing is written to disk.
|
|
22
|
+
#
|
|
23
|
+
# == Sub-agents
|
|
24
|
+
#
|
|
25
|
+
# Sub-agents do not inherit extensions (see CLAUDE.md's "Seams").
|
|
26
|
+
# Concretely: a sub-agent spawned by the +agent+ tool gets a
|
|
27
|
+
# fresh persona, fresh toolset, no task list. That keeps the
|
|
28
|
+
# parent's plan private to the parent and avoids the
|
|
29
|
+
# who-owns-which-task confusion a shared list would produce.
|
|
30
|
+
# If a host wants the sub-agent to also have a task list, it
|
|
31
|
+
# adds the extension to the sub-agent's own configurator —
|
|
32
|
+
# cleanly opt-in, no implicit sharing.
|
|
33
|
+
#
|
|
34
|
+
# == Empty default
|
|
35
|
+
#
|
|
36
|
+
# No +catalog+-style empty state: registering the extension
|
|
37
|
+
# always installs the four tools and the snippet. A host that
|
|
38
|
+
# doesn't want tasks simply omits the extension.
|
|
39
|
+
class Extension
|
|
40
|
+
include Pikuri::Agent::Extension
|
|
41
|
+
|
|
42
|
+
# System-prompt snippet appended once per agent. Short by
|
|
43
|
+
# design: rules-of-thumb only, no inventory (the tool
|
|
44
|
+
# descriptions cover their own usage). Mirrors the shape of
|
|
45
|
+
# opencode's +todowrite.txt+ but condensed to fit pikuri's
|
|
46
|
+
# "short prose over abstract framing" docs convention.
|
|
47
|
+
#
|
|
48
|
+
# @return [String]
|
|
49
|
+
PROMPT_SNIPPET = <<~PROMPT
|
|
50
|
+
<tasks_usage>
|
|
51
|
+
You have an in-memory task list. Use it to plan and track multi-step work.
|
|
52
|
+
|
|
53
|
+
Workflow:
|
|
54
|
+
- When a task has 3+ steps, call `task_create` once with the full plan (newline-separated items, all start as `pending`).
|
|
55
|
+
- Before starting an item, call `task_in_progress` with its exact content. Keep exactly one item `in_progress` at a time.
|
|
56
|
+
- When an item is fully done (including any required verification), call `task_completed` with its exact content.
|
|
57
|
+
- Use `task_delete` to remove items that turn out not to be needed.
|
|
58
|
+
|
|
59
|
+
Skip task tracking entirely for single-step or purely informational requests — it adds noise, not value.
|
|
60
|
+
|
|
61
|
+
Every mutation returns the full current list, so you do not need a separate read tool. Content doubles as identifier across the four tools: spelling and capitalization must match exactly.
|
|
62
|
+
</tasks_usage>
|
|
63
|
+
PROMPT
|
|
64
|
+
|
|
65
|
+
# Tool classes the extension auto-registers. Used both for
|
|
66
|
+
# construction and for the duplicate-registration guard below.
|
|
67
|
+
TOOL_CLASSES = [Create, InProgress, Completed, Delete].freeze
|
|
68
|
+
|
|
69
|
+
# @return [Extension]
|
|
70
|
+
def initialize
|
|
71
|
+
@list = List.new
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [List] the per-agent list, exposed for tests and for
|
|
75
|
+
# hosts that want to render it in a UI (a future TUI could
|
|
76
|
+
# surface +list.items+ in a sidebar).
|
|
77
|
+
attr_reader :list
|
|
78
|
+
|
|
79
|
+
# Construct the four tools (each sharing +@list+) and register
|
|
80
|
+
# them, then append {PROMPT_SNIPPET}. Raises if any of the four
|
|
81
|
+
# tool classes have been pre-registered via +c.add_tool+ — the
|
|
82
|
+
# whole point of the extension is to be the single owner of the
|
|
83
|
+
# shared list, and a manually pre-registered tool would bind to
|
|
84
|
+
# a different list.
|
|
85
|
+
#
|
|
86
|
+
# @param c [Pikuri::Agent::Configurator]
|
|
87
|
+
# @return [void]
|
|
88
|
+
def configure(c)
|
|
89
|
+
TOOL_CLASSES.each do |cls|
|
|
90
|
+
if c.tools.any?(cls)
|
|
91
|
+
raise "#{cls} cannot be pre-registered (in tools: or via c.add_tool) " \
|
|
92
|
+
'when adding Pikuri::Tasks::Extension — the extension auto-registers all four task tools ' \
|
|
93
|
+
'so they share the same in-memory list.'
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
TOOL_CLASSES.each { |cls| c.add_tool(cls.new(list: @list)) }
|
|
98
|
+
c.append_system_prompt(PROMPT_SNIPPET)
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Tasks
|
|
5
|
+
# The +task_in_progress+ tool: mark the item whose +content+
|
|
6
|
+
# exactly matches as +in_progress+. The status name is baked into
|
|
7
|
+
# the tool name rather than passed as a parameter — that takes
|
|
8
|
+
# one degree of freedom away from the LLM (no +"in-progress"+ vs
|
|
9
|
+
# +"in_progress"+ vs +"inprogress"+ typos) at the cost of one
|
|
10
|
+
# extra tool class.
|
|
11
|
+
#
|
|
12
|
+
# Returns the rendered current list via {List#render} on success,
|
|
13
|
+
# or +"Error: no such task: '<content>'"+ when the content does
|
|
14
|
+
# not match — the LLM can read the returned list to find the
|
|
15
|
+
# closest match and re-call.
|
|
16
|
+
class InProgress < Pikuri::Tool
|
|
17
|
+
# @return [String]
|
|
18
|
+
DESCRIPTION = <<~DESC
|
|
19
|
+
Mark a task as `in_progress` immediately before you start working on it.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
- Pass the exact `content` string the task was created with — content doubles as identifier; spelling and capitalization must match.
|
|
23
|
+
- Keep exactly one task `in_progress` at a time. Finish (or revert) the current one before starting another.
|
|
24
|
+
- On `Error: no such task: ...` the call did nothing — read the returned list in any subsequent tool's output to pick the right name.
|
|
25
|
+
- On success the full current list is returned for you to read back.
|
|
26
|
+
DESC
|
|
27
|
+
|
|
28
|
+
# @param list [List]
|
|
29
|
+
# @return [InProgress]
|
|
30
|
+
def initialize(list:)
|
|
31
|
+
super(
|
|
32
|
+
name: 'task_in_progress',
|
|
33
|
+
description: DESCRIPTION,
|
|
34
|
+
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
35
|
+
p.required_string :content,
|
|
36
|
+
'Exact content of the existing task to mark as ' \
|
|
37
|
+
'in_progress, e.g. "Add dark mode toggle".'
|
|
38
|
+
},
|
|
39
|
+
execute: lambda { |content:|
|
|
40
|
+
InProgress.execute(list: list, content: content)
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param list [List]
|
|
46
|
+
# @param content [String]
|
|
47
|
+
# @return [String]
|
|
48
|
+
def self.execute(list:, content:)
|
|
49
|
+
list.set_status(content: content, status: 'in_progress')
|
|
50
|
+
list.render
|
|
51
|
+
rescue ItemNotFound
|
|
52
|
+
"Error: no such task: '#{content}'"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Tasks
|
|
5
|
+
# Raised by {List#add} when a new item's +content+ already appears
|
|
6
|
+
# in the list. Content doubles as identifier in the task tools, so
|
|
7
|
+
# duplicates would make +task_in_progress+ / +task_completed+ /
|
|
8
|
+
# +task_delete+ ambiguous. The exception carries the offending
|
|
9
|
+
# content string so the tool layer can surface it verbatim in the
|
|
10
|
+
# +"Error: ..."+ observation.
|
|
11
|
+
class DuplicateItem < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Raised by {List#set_status} and {List#delete} when no item with
|
|
14
|
+
# the given +content+ exists. The four task tools rescue this and
|
|
15
|
+
# report +"Error: no such task: <content>"+ so the LLM can correct
|
|
16
|
+
# the name on the next turn.
|
|
17
|
+
class ItemNotFound < StandardError; end
|
|
18
|
+
|
|
19
|
+
# Allowed values for {Item#status}. Kept deliberately small — three
|
|
20
|
+
# states match the workflow the prompt advertises (pending → in_progress
|
|
21
|
+
# → completed) without inviting a +cancelled+ vs +deleted+ debate.
|
|
22
|
+
# +task_delete+ removes items outright rather than introducing a
|
|
23
|
+
# fourth status.
|
|
24
|
+
STATUSES = %w[pending in_progress completed].freeze
|
|
25
|
+
|
|
26
|
+
# One row in a {List}. +content+ is the LLM-visible string that
|
|
27
|
+
# also acts as identifier across the four task tools; +status+ is
|
|
28
|
+
# one of {STATUSES}.
|
|
29
|
+
Item = Data.define(:content, :status)
|
|
30
|
+
|
|
31
|
+
# An in-memory ordered list of {Item}s, scoped to a single
|
|
32
|
+
# {Pikuri::Agent}. Held inside {Extension} and captured by closure
|
|
33
|
+
# into the +execute+ block of each of the four task tool classes,
|
|
34
|
+
# so every mutation hits the same instance.
|
|
35
|
+
#
|
|
36
|
+
# == Concurrency
|
|
37
|
+
#
|
|
38
|
+
# The agent loop is single-threaded with respect to tool calls
|
|
39
|
+
# (ruby_llm dispatches them sequentially), so no locking. A future
|
|
40
|
+
# parallel-tool-execution feature would need a +Mutex+ here.
|
|
41
|
+
#
|
|
42
|
+
# == Persistence
|
|
43
|
+
#
|
|
44
|
+
# None. The list is dropped when the +Agent+ is garbage-collected.
|
|
45
|
+
# That matches the gem's stated scope: "in-memory only, no
|
|
46
|
+
# session-state-on-disk."
|
|
47
|
+
class List
|
|
48
|
+
# @return [List]
|
|
49
|
+
def initialize
|
|
50
|
+
@items = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Array<Item>] a frozen snapshot of the current items,
|
|
54
|
+
# in insertion order. Callers cannot mutate the internal
|
|
55
|
+
# storage through this accessor.
|
|
56
|
+
def items
|
|
57
|
+
@items.dup.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Integer]
|
|
61
|
+
def size
|
|
62
|
+
@items.size
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def empty?
|
|
67
|
+
@items.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Append a new item with status +pending+. Content matching is
|
|
71
|
+
# exact (no case- or whitespace-folding) since the tools quote
|
|
72
|
+
# the content back to the LLM as the identifier.
|
|
73
|
+
#
|
|
74
|
+
# @param content [String] non-empty content; whitespace is the
|
|
75
|
+
# caller's responsibility.
|
|
76
|
+
# @return [Item] the newly added item
|
|
77
|
+
# @raise [DuplicateItem] if an item with the same +content+
|
|
78
|
+
# already exists.
|
|
79
|
+
def add(content)
|
|
80
|
+
raise DuplicateItem, content if find(content)
|
|
81
|
+
|
|
82
|
+
item = Item.new(content: content, status: 'pending')
|
|
83
|
+
@items << item
|
|
84
|
+
item
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Update the status of the item whose +content+ matches.
|
|
88
|
+
#
|
|
89
|
+
# @param content [String]
|
|
90
|
+
# @param status [String] one of {STATUSES}.
|
|
91
|
+
# @return [Item] the updated item (a fresh frozen +Data+
|
|
92
|
+
# instance — the old one is replaced in place).
|
|
93
|
+
# @raise [ItemNotFound] if no matching item exists.
|
|
94
|
+
# @raise [ArgumentError] if +status+ is not in {STATUSES}.
|
|
95
|
+
def set_status(content:, status:)
|
|
96
|
+
unless STATUSES.include?(status)
|
|
97
|
+
raise ArgumentError, "invalid status: #{status.inspect} (allowed: #{STATUSES.join(', ')})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
idx = @items.index { |i| i.content == content }
|
|
101
|
+
raise ItemNotFound, content if idx.nil?
|
|
102
|
+
|
|
103
|
+
@items[idx] = Item.new(content: content, status: status)
|
|
104
|
+
@items[idx]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Remove the item whose +content+ matches.
|
|
108
|
+
#
|
|
109
|
+
# @param content [String]
|
|
110
|
+
# @return [Item] the removed item.
|
|
111
|
+
# @raise [ItemNotFound] if no matching item exists.
|
|
112
|
+
def delete(content)
|
|
113
|
+
idx = @items.index { |i| i.content == content }
|
|
114
|
+
raise ItemNotFound, content if idx.nil?
|
|
115
|
+
|
|
116
|
+
@items.delete_at(idx)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The canonical rendering returned as the observation by every
|
|
120
|
+
# task tool, so the LLM sees the latest full state on each call
|
|
121
|
+
# without needing a separate read tool. Format:
|
|
122
|
+
#
|
|
123
|
+
# <tasks>
|
|
124
|
+
# - [pending] Add dark mode toggle
|
|
125
|
+
# - [in_progress] Write unit tests
|
|
126
|
+
# - [completed] Update README
|
|
127
|
+
# </tasks>
|
|
128
|
+
#
|
|
129
|
+
# Empty list renders as +<tasks>(empty)</tasks>+ so the LLM gets
|
|
130
|
+
# an unambiguous "yes, the call worked and the list is now empty"
|
|
131
|
+
# signal rather than an ambiguous blank block.
|
|
132
|
+
#
|
|
133
|
+
# @return [String]
|
|
134
|
+
def render
|
|
135
|
+
return '<tasks>(empty)</tasks>' if @items.empty?
|
|
136
|
+
|
|
137
|
+
lines = @items.map { |i| "- [#{i.status}] #{i.content}" }
|
|
138
|
+
"<tasks>\n#{lines.join("\n")}\n</tasks>"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def find(content)
|
|
144
|
+
@items.find { |i| i.content == content }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/pikuri-tasks.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pikuri-core'
|
|
4
|
+
|
|
5
|
+
# Entry file for the pikuri-tasks gem. Sets up a dedicated Zeitwerk
|
|
6
|
+
# loader rooted at this gem's +lib/+, contributing to the shared
|
|
7
|
+
# +Pikuri::+ namespace alongside pikuri-core. After +require
|
|
8
|
+
# 'pikuri-tasks'+, +Pikuri::Tasks::List+, the four task tool classes
|
|
9
|
+
# (+Create+, +InProgress+, +Completed+, +Delete+), and
|
|
10
|
+
# +Pikuri::Tasks::Extension+ are all defined.
|
|
11
|
+
#
|
|
12
|
+
# The loader is per-gem (not shared with pikuri-core's loader) so each
|
|
13
|
+
# gem owns its own +lib/+ tree and the cooperation between gems is via
|
|
14
|
+
# the Pikuri namespace alone.
|
|
15
|
+
module Pikuri
|
|
16
|
+
module Tasks
|
|
17
|
+
LOADER = Zeitwerk::Loader.new
|
|
18
|
+
LOADER.tag = 'pikuri-tasks'
|
|
19
|
+
LOADER.push_dir(File.expand_path('.', __dir__))
|
|
20
|
+
LOADER.ignore(__FILE__)
|
|
21
|
+
LOADER.setup
|
|
22
|
+
LOADER.eager_load
|
|
23
|
+
end
|
|
24
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pikuri-tasks
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.4
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Martin Vysny
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: pikuri-core
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - '='
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.0.4
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - '='
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 0.0.4
|
|
27
|
+
description: |
|
|
28
|
+
pikuri-tasks gives a pikuri-core agent an in-memory task list it
|
|
29
|
+
can use to plan and track multi-step work. A +Pikuri::Tasks::List+
|
|
30
|
+
holds the per-Agent state; four tools (+task_create+,
|
|
31
|
+
+task_in_progress+, +task_completed+, +task_delete+) mutate it via
|
|
32
|
+
content-as-identifier (no item IDs to hallucinate).
|
|
33
|
+
+Pikuri::Tasks::Extension+ wires the list and tools onto an
|
|
34
|
+
+Pikuri::Agent+ via +c.add_extension(...)+ inside the +Agent.new+
|
|
35
|
+
block.
|
|
36
|
+
|
|
37
|
+
The list lives in process memory only — nothing is written to disk.
|
|
38
|
+
Sub-agents do not inherit the parent's list (consistent with the
|
|
39
|
+
"sub-agents do not inherit extensions" rule).
|
|
40
|
+
email:
|
|
41
|
+
- martin@vysny.me
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- README.md
|
|
47
|
+
- lib/pikuri-tasks.rb
|
|
48
|
+
- lib/pikuri/tasks/completed.rb
|
|
49
|
+
- lib/pikuri/tasks/create.rb
|
|
50
|
+
- lib/pikuri/tasks/delete.rb
|
|
51
|
+
- lib/pikuri/tasks/extension.rb
|
|
52
|
+
- lib/pikuri/tasks/in_progress.rb
|
|
53
|
+
- lib/pikuri/tasks/list.rb
|
|
54
|
+
homepage: https://codeberg.org/mvysny/pikuri
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
source_code_uri: https://codeberg.org/mvysny/pikuri/src/branch/master
|
|
59
|
+
changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
|
|
60
|
+
bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
|
|
61
|
+
rubygems_mfa_required: 'true'
|
|
62
|
+
post_install_message:
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '3.3'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.5.22
|
|
78
|
+
signing_key:
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Per-session in-memory task list + tools for pikuri.
|
|
81
|
+
test_files: []
|