pikuri-tasks 0.0.6 → 0.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ceab6f3f6999fb22176982adb511db13d445cba7044a435492071f2308f4eae
4
- data.tar.gz: da032f405772ada2943df18113c1b4670799003069c5f329676d4bdc765d4397
3
+ metadata.gz: 113302bc63d0e9bd10c56550592d8e0aee68212fd1ee2cfe4032b8d1f2836b2c
4
+ data.tar.gz: 7d9e4351247953a5b7d7f5e54acbd68ace82404966bdeca47389b81804f2714e
5
5
  SHA512:
6
- metadata.gz: 7e74049b277b94b546f6e7a6a237e0e595aee40c7bd78fb7029bdb75c9eca36d52144ea97cd71f7215dfcf7a1dbea8c8270c6ef6f8125bd256b27c413d8a2566
7
- data.tar.gz: 8a46f113b26b1014b34b434fe8b2a79eccef7c2a587dc4ffff8ead471b96ebbf5f8c4cb642934938ef244b39cc5cde6ea4f6ac9a0d272322edbccff05c5a5e24
6
+ metadata.gz: b287b65b9b99bd77966901c622aa503942cd02b09bbc8b10a2a43451dac01754136666bb7df53473990d31eed5ff2a5bd41213bdff08635bfe9657d92bce1412
7
+ data.tar.gz: 760eed62da11196fd09c3bcbb5bd8036e3697683bedd6d7c3eeb86ed5ce1aa2baeaf325b1780bb9ab285ef38ee12d8ec3b65773b794945cbf6ff76898b17b64f
data/README.md CHANGED
@@ -5,19 +5,19 @@ In-memory task list + four LLM-facing tools for the
5
5
 
6
6
  Provides:
7
7
  - `Pikuri::Tasks::List` — per-Agent in-memory list of
8
- `(content, status)` items. Status is one of `pending`,
8
+ `(id, content, status)` items. Status is one of `pending`,
9
9
  `in_progress`, `completed`. Nothing is written to disk.
10
10
  - Four tool classes, all sharing one `List` instance:
11
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.
12
+ items from a JSON array of strings. Atomic: if any element is
13
+ blank or a duplicate (within the batch or already on the
14
+ list), nothing is added.
15
15
  - `Pikuri::Tasks::InProgress` (`task_in_progress`) — mark an item
16
- as `in_progress` by content.
16
+ as `in_progress` by numeric id.
17
17
  - `Pikuri::Tasks::Completed` (`task_completed`) — mark an item as
18
- `completed` by content.
18
+ `completed` by numeric id.
19
19
  - `Pikuri::Tasks::Delete` (`task_delete`) — remove an item by
20
- content.
20
+ numeric id.
21
21
  - `Pikuri::Tasks::Extension` — wires the four tools + a brief
22
22
  `<tasks_usage>` workflow snippet into a `Pikuri::Agent` via the
23
23
  `c.add_extension(...)` block API.
@@ -25,10 +25,12 @@ Provides:
25
25
  Two shape choices worth flagging:
26
26
  - **Status is baked into the tool name** (no `status:` parameter
27
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.
28
+ vs `"inprogress"` typo failure mode.
29
+ - **Items are addressed by numeric id** assigned on create,
30
+ shown in every rendered list, never reused after a delete. A
31
+ near-miss when re-typing the task's content cannot lock the
32
+ model out of its own list; duplicates are still rejected on
33
+ `task_create` because they are almost always a mistake.
32
34
 
33
35
  ## Install
34
36
 
@@ -59,9 +61,9 @@ so the LLM always sees fresh state without a separate read tool:
59
61
 
60
62
  ```
61
63
  <tasks>
62
- - [pending] Add dark mode toggle
63
- - [in_progress] Write unit tests
64
- - [completed] Update README
64
+ - #1 [pending] Add dark mode toggle
65
+ - #2 [in_progress] Write unit tests
66
+ - #3 [completed] Update README
65
67
  </tasks>
66
68
  ```
67
69
 
@@ -2,25 +2,25 @@
2
2
 
3
3
  module Pikuri
4
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"+.
5
+ # The +task_completed+ tool: mark the item with the given +id+ as
6
+ # +completed+. Same shape and rationale as {InProgress} — the
7
+ # status is baked into the tool name to remove the enum-typo
8
+ # failure mode (+"completed"+ / +"complete"+ / +"done"+), and the
9
+ # item is addressed by the numeric +#id+ from the rendered list.
10
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.
11
+ # Returns the rendered current list via {List#render} on success.
12
+ # On a bad id returns +"Error: no such task id: <id>"+ plus the
13
+ # current list, so the LLM can pick the right id in one turn.
14
14
  class Completed < Pikuri::Tool
15
15
  # @return [String]
16
16
  DESCRIPTION = <<~DESC
17
17
  Mark a task as `completed` once the work — including any required verification — is actually done.
18
18
 
19
19
  Usage:
20
- - Pass the exact `content` string the task was created with.
20
+ - Pass the task's numeric `id` as shown in the rendered list (`- #3 [in_progress] ...` → id 3).
21
21
  - Do NOT mark `completed` based on intent; mark it only after the underlying work is verified.
22
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.
23
+ - On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
24
24
  - On success the full current list is returned for you to read back.
25
25
  DESC
26
26
 
@@ -31,24 +31,24 @@ module Pikuri
31
31
  name: 'task_completed',
32
32
  description: DESCRIPTION,
33
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".'
34
+ p.required_integer :id,
35
+ 'Numeric id of the existing task to mark as ' \
36
+ 'completed, as shown in the rendered list, e.g. 3.'
37
37
  },
38
- execute: lambda { |content:|
39
- Completed.execute(list: list, content: content)
38
+ execute: lambda { |id:|
39
+ Completed.execute(list: list, id: id)
40
40
  }
41
41
  )
42
42
  end
43
43
 
44
44
  # @param list [List]
45
- # @param content [String]
45
+ # @param id [Integer]
46
46
  # @return [String]
47
- def self.execute(list:, content:)
48
- list.set_status(content: content, status: 'completed')
47
+ def self.execute(list:, id:)
48
+ list.set_status(id: id, status: 'completed')
49
49
  list.render
50
50
  rescue ItemNotFound
51
- "Error: no such task: '#{content}'"
51
+ "Error: no such task id: #{id}. Current list:\n#{list.render}"
52
52
  end
53
53
  end
54
54
  end
@@ -3,22 +3,27 @@
3
3
  module Pikuri
4
4
  module Tasks
5
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.
6
+ # call from a JSON array of strings — a native +items+ array
7
+ # parameter, the shape every mainstream harness's task tool uses
8
+ # and therefore the shape the model's training prior produces
9
+ # unprompted. (An earlier newline-separated-string design tried to
10
+ # spare small models the bracket-balancing; in practice models
11
+ # sent JSON arrays anyway the prior beats the parameter
12
+ # description — and the splitter turned `[`, `"foo",`, `]` into
13
+ # garbage tasks. Match the prior instead of fighting it.)
12
14
  #
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
15
+ # Each element is whitespace-stripped; a blank element aborts the
16
+ # call (it is always an LLM mistake, never intent). If any input
17
+ # is a duplicate (within the batch, or already in the list), the
18
+ # whole call likewise aborts with an +"Error: ..."+ string and
19
+ # nothing is added — the LLM resends a corrected batch on the
17
20
  # next turn. Atomic semantics keep the list in a coherent state
18
21
  # the LLM doesn't have to reconcile.
19
22
  #
20
- # On success returns the rendered current list via {List#render},
21
- # so the LLM always sees fresh state without a separate read tool.
23
+ # On success returns the rendered current list via {List#render}
24
+ # including each new item's +#id+, which the three mutation tools
25
+ # address items by — so the LLM always sees fresh state without a
26
+ # separate read tool.
22
27
  class Create < Pikuri::Tool
23
28
  # @return [String] static description shown to the LLM,
24
29
  # opencode-shape (summary + +Usage:+ bullets).
@@ -27,10 +32,9 @@ module Pikuri
27
32
 
28
33
  Usage:
29
34
  - 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.
35
+ - `items` is a JSON array of strings — one task per element.
36
+ - A blank element, a duplicate (within the batch or already on the list), or an empty array aborts the whole call with `Error: ...` and adds nothing — resend a corrected batch.
37
+ - On success the full current list is returned, with each task's `#id` — use that id with `task_in_progress` / `task_completed` / `task_delete`.
34
38
  DESC
35
39
 
36
40
  # @param list [List] the shared per-Agent list, captured by
@@ -42,10 +46,9 @@ module Pikuri
42
46
  name: 'task_create',
43
47
  description: DESCRIPTION,
44
48
  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
+ p.required_string_array :items,
50
+ 'Task contents, one task per array element, e.g. ' \
51
+ '["Add dark mode toggle", "Write unit tests", "Update README"].'
49
52
  },
50
53
  execute: lambda { |items:|
51
54
  Create.execute(list: list, items: items)
@@ -57,12 +60,16 @@ module Pikuri
57
60
  # without constructing a tool instance.
58
61
  #
59
62
  # @param list [List]
60
- # @param items [String] raw +items+ argument from the LLM.
63
+ # @param items [Array<String>] raw +items+ argument from the LLM
64
+ # (already type-validated by {Pikuri::Tool::Parameters}).
61
65
  # @return [String] either {List#render} on success or an
62
66
  # +"Error: ..."+ string the LLM can react to.
63
67
  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?
68
+ return 'Error: task_create requires at least one item' if items.empty?
69
+
70
+ cleaned = items.map(&:strip)
71
+ blank = cleaned.index('')
72
+ return "Error: blank item at index #{blank} — every element must be non-blank task text" if blank
66
73
 
67
74
  seen_in_batch = {}
68
75
  cleaned.each do |c|
@@ -2,29 +2,31 @@
2
2
 
3
3
  module Pikuri
4
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.
5
+ # The +task_delete+ tool: remove the item with the given +id+ from
6
+ # the list. Used to drop items that turned out to be unnecessary,
7
+ # were created in error, or have been superseded — rather than
8
+ # leaving them sitting in +pending+ as visual noise.
9
9
  #
10
10
  # Distinct from {Completed}: +completed+ means the work was
11
11
  # actually done; +delete+ means the task should never have been
12
12
  # there. The list itself draws no such distinction once an item
13
13
  # is gone, but the LLM picks the right verb because the tool
14
- # names make the intent clear.
14
+ # names make the intent clear. A deleted item's id is never
15
+ # reused (see {List#add}), so a stale id errors loudly instead of
16
+ # silently hitting a newer task.
15
17
  #
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.
18
+ # Returns the rendered current list via {List#render} on success.
19
+ # On a bad id returns +"Error: no such task id: <id>"+ plus the
20
+ # current list, so the LLM can pick the right id in one turn.
19
21
  class Delete < Pikuri::Tool
20
22
  # @return [String]
21
23
  DESCRIPTION = <<~DESC
22
24
  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
25
 
24
26
  Usage:
25
- - Pass the exact `content` string the task was created with.
27
+ - Pass the task's numeric `id` as shown in the rendered list (`- #3 [pending] ...` → id 3).
26
28
  - 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.
29
+ - On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
28
30
  - On success the full current list is returned for you to read back.
29
31
  DESC
30
32
 
@@ -35,24 +37,24 @@ module Pikuri
35
37
  name: 'task_delete',
36
38
  description: DESCRIPTION,
37
39
  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".'
40
+ p.required_integer :id,
41
+ 'Numeric id of the existing task to remove, ' \
42
+ 'as shown in the rendered list, e.g. 3.'
41
43
  },
42
- execute: lambda { |content:|
43
- Delete.execute(list: list, content: content)
44
+ execute: lambda { |id:|
45
+ Delete.execute(list: list, id: id)
44
46
  }
45
47
  )
46
48
  end
47
49
 
48
50
  # @param list [List]
49
- # @param content [String]
51
+ # @param id [Integer]
50
52
  # @return [String]
51
- def self.execute(list:, content:)
52
- list.delete(content)
53
+ def self.execute(list:, id:)
54
+ list.delete(id)
53
55
  list.render
54
56
  rescue ItemNotFound
55
- "Error: no such task: '#{content}'"
57
+ "Error: no such task id: #{id}. Current list:\n#{list.render}"
56
58
  end
57
59
  end
58
60
  end
@@ -3,13 +3,16 @@
3
3
  module Pikuri
4
4
  # Namespace for the in-memory task-list feature. Holds the
5
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}.
6
+ # {InProgress}, {Completed}, {Delete}), the {ListChanged} domain
7
+ # event, and the {Extension} that wires them into an
8
+ # {Pikuri::Agent}.
8
9
  module Tasks
9
10
  # An {Pikuri::Agent::Extension} that auto-wires an in-memory
10
11
  # 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.
12
+ # the four task tool classes against it, appends a brief
13
+ # workflow snippet to the system prompt, and (in {#bind}) arms
14
+ # {ListChanged} emission so listeners can observe every list
15
+ # mutation.
13
16
  #
14
17
  # == Usage
15
18
  #
@@ -51,14 +54,14 @@ module Pikuri
51
54
  You have an in-memory task list. Use it to plan and track multi-step work.
52
55
 
53
56
  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
+ - When a task has 3+ steps, call `task_create` once with the full plan (a JSON array of strings, all start as `pending`).
58
+ - Before starting an item, call `task_in_progress` with its numeric id. Keep exactly one item `in_progress` at a time.
59
+ - When an item is fully done (including any required verification), call `task_completed` with its numeric id.
57
60
  - Use `task_delete` to remove items that turn out not to be needed.
58
61
 
59
62
  Skip task tracking entirely for single-step or purely informational requests — it adds noise, not value.
60
63
 
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.
64
+ Every mutation returns the full current list, with each task's id (`- #3 [pending] ...` → id 3), so you do not need a separate read tool. Ids never change and are never reused.
62
65
  </tasks_usage>
63
66
  PROMPT
64
67
 
@@ -71,9 +74,11 @@ module Pikuri
71
74
  @list = List.new
72
75
  end
73
76
 
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
+ # @return [List] the per-agent list, exposed for tests. UI
78
+ # hosts should NOT read it from another thread the list is
79
+ # agent-thread-confined; consume the {ListChanged} events
80
+ # wired by {#bind} instead (each carries an immutable
81
+ # snapshot safe to render from anywhere).
77
82
  attr_reader :list
78
83
 
79
84
  # Construct the four tools (each sharing +@list+) and register
@@ -98,6 +103,20 @@ module Pikuri
98
103
  c.append_system_prompt(PROMPT_SNIPPET)
99
104
  nil
100
105
  end
106
+
107
+ # Arm {List#on_change} to emit a {ListChanged} (carrying a
108
+ # fresh {List#items} snapshot) onto the agent's listener
109
+ # stream after every mutation. This is what lets a UI listener
110
+ # observe the task list without ever touching the
111
+ # agent-thread-confined {List} — see the Concurrency note on
112
+ # {List}.
113
+ #
114
+ # @param ctx [Pikuri::Agent::ExtensionContext]
115
+ # @return [void]
116
+ def bind(ctx)
117
+ @list.on_change = -> { ctx.emit_event(ListChanged.new(items: @list.items)) }
118
+ nil
119
+ end
101
120
  end
102
121
  end
103
122
  end
@@ -2,26 +2,30 @@
2
2
 
3
3
  module Pikuri
4
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.
5
+ # The +task_in_progress+ tool: mark the item with the given +id+
6
+ # as +in_progress+. The status name is baked into the tool name
7
+ # rather than passed as a parameter — that takes one degree of
8
+ # freedom away from the LLM (no +"in-progress"+ vs +"in_progress"+
9
+ # vs +"inprogress"+ typos) at the cost of one extra tool class.
11
10
  #
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.
11
+ # Items are addressed by the numeric +#id+ shown in every rendered
12
+ # list (an earlier design used the content string as identifier;
13
+ # one near-miss in reproducing the exact bytes a stray quote, a
14
+ # trailing comma and the LLM is locked out of its own list. An
15
+ # id is two digits it just read back; nothing to mis-transcribe.)
16
+ #
17
+ # Returns the rendered current list via {List#render} on success.
18
+ # On a bad id returns +"Error: no such task id: <id>"+ plus the
19
+ # current list, so the LLM can pick the right id in one turn.
16
20
  class InProgress < Pikuri::Tool
17
21
  # @return [String]
18
22
  DESCRIPTION = <<~DESC
19
23
  Mark a task as `in_progress` immediately before you start working on it.
20
24
 
21
25
  Usage:
22
- - Pass the exact `content` string the task was created with content doubles as identifier; spelling and capitalization must match.
26
+ - Pass the task's numeric `id` as shown in the rendered list (`- #3 [pending] ...` id 3).
23
27
  - 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.
28
+ - On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
25
29
  - On success the full current list is returned for you to read back.
26
30
  DESC
27
31
 
@@ -32,24 +36,24 @@ module Pikuri
32
36
  name: 'task_in_progress',
33
37
  description: DESCRIPTION,
34
38
  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".'
39
+ p.required_integer :id,
40
+ 'Numeric id of the existing task to mark as ' \
41
+ 'in_progress, as shown in the rendered list, e.g. 3.'
38
42
  },
39
- execute: lambda { |content:|
40
- InProgress.execute(list: list, content: content)
43
+ execute: lambda { |id:|
44
+ InProgress.execute(list: list, id: id)
41
45
  }
42
46
  )
43
47
  end
44
48
 
45
49
  # @param list [List]
46
- # @param content [String]
50
+ # @param id [Integer]
47
51
  # @return [String]
48
- def self.execute(list:, content:)
49
- list.set_status(content: content, status: 'in_progress')
52
+ def self.execute(list:, id:)
53
+ list.set_status(id: id, status: 'in_progress')
50
54
  list.render
51
55
  rescue ItemNotFound
52
- "Error: no such task: '#{content}'"
56
+ "Error: no such task id: #{id}. Current list:\n#{list.render}"
53
57
  end
54
58
  end
55
59
  end
@@ -3,17 +3,18 @@
3
3
  module Pikuri
4
4
  module Tasks
5
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.
6
+ # in the list. Items are addressed by {Item#id}, so duplicates
7
+ # would not be ambiguous they are rejected because a duplicate
8
+ # is almost always an LLM mistake (re-sending an already-captured
9
+ # plan), and catching it early keeps the list coherent. The
10
+ # exception carries the offending content string so the tool layer
11
+ # can surface it verbatim in the +"Error: ..."+ observation.
11
12
  class DuplicateItem < StandardError; end
12
13
 
13
14
  # 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.
15
+ # the given +id+ exists. The three mutation tools rescue this and
16
+ # report +"Error: no such task id: <id>"+ plus the current list
17
+ # render, so the LLM can pick the right id on the next turn.
17
18
  class ItemNotFound < StandardError; end
18
19
 
19
20
  # Allowed values for {Item#status}. Kept deliberately small — three
@@ -23,10 +24,11 @@ module Pikuri
23
24
  # fourth status.
24
25
  STATUSES = %w[pending in_progress completed].freeze
25
26
 
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
27
+ # One row in a {List}. +id+ is the numeric identifier the mutation
28
+ # tools address the item by (assigned by {List#add}, never reused
29
+ # see there); +content+ is the LLM-visible task text; +status+ is
28
30
  # one of {STATUSES}.
29
- Item = Data.define(:content, :status)
31
+ Item = Data.define(:id, :content, :status)
30
32
 
31
33
  # An in-memory ordered list of {Item}s, scoped to a single
32
34
  # {Pikuri::Agent}. Held inside {Extension} and captured by closure
@@ -35,8 +37,13 @@ module Pikuri
35
37
  #
36
38
  # == Concurrency
37
39
  #
38
- # The agent loop is single-threaded with respect to tool calls
39
- # (ruby_llm dispatches them sequentially), so no locking. A future
40
+ # The list is confined to the agent's thread: the agent loop is
41
+ # single-threaded with respect to tool calls (ruby_llm dispatches
42
+ # them sequentially), so no locking. Other threads (e.g. a web UI
43
+ # rendering the list) must not touch a +List+ directly — they
44
+ # consume the {ListChanged} events {Extension#bind} wires onto
45
+ # the listener stream, whose +items+ payload is an immutable
46
+ # snapshot safe to hand across threads. A future
40
47
  # parallel-tool-execution feature would need a +Mutex+ here.
41
48
  #
42
49
  # == Persistence
@@ -48,8 +55,21 @@ module Pikuri
48
55
  # @return [List]
49
56
  def initialize
50
57
  @items = []
58
+ @next_id = 1
59
+ @on_change = nil
51
60
  end
52
61
 
62
+ # Optional zero-argument hook invoked after every successful
63
+ # mutation ({#add} / {#set_status} / {#delete}) — not on failed
64
+ # ones (a raise means nothing changed). Set by {Extension#bind}
65
+ # to emit a {ListChanged} onto the agent's listener stream;
66
+ # +nil+ (the default) disables notification. Runs on the
67
+ # mutating (agent) thread, synchronously inside the mutation
68
+ # call.
69
+ #
70
+ # @return [Proc, nil]
71
+ attr_accessor :on_change
72
+
53
73
  # @return [Array<Item>] a frozen snapshot of the current items,
54
74
  # in insertion order. Callers cannot mutate the internal
55
75
  # storage through this accessor.
@@ -67,63 +87,71 @@ module Pikuri
67
87
  @items.empty?
68
88
  end
69
89
 
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.
90
+ # Append a new item with status +pending+ and the next id from a
91
+ # monotonic per-list counter. Ids are never reused: after a
92
+ # delete, the freed id stays dead, so a stale id held by the LLM
93
+ # errors loudly instead of silently resolving to a newer task.
73
94
  #
74
95
  # @param content [String] non-empty content; whitespace is the
75
96
  # caller's responsibility.
76
97
  # @return [Item] the newly added item
77
98
  # @raise [DuplicateItem] if an item with the same +content+
78
- # already exists.
99
+ # already exists (a duplicate is almost always an LLM mistake).
79
100
  def add(content)
80
- raise DuplicateItem, content if find(content)
101
+ raise DuplicateItem, content if @items.any? { |i| i.content == content }
81
102
 
82
- item = Item.new(content: content, status: 'pending')
103
+ item = Item.new(id: @next_id, content: content, status: 'pending')
104
+ @next_id += 1
83
105
  @items << item
106
+ @on_change&.call
84
107
  item
85
108
  end
86
109
 
87
- # Update the status of the item whose +content+ matches.
110
+ # Update the status of the item whose +id+ matches.
88
111
  #
89
- # @param content [String]
112
+ # @param id [Integer]
90
113
  # @param status [String] one of {STATUSES}.
91
114
  # @return [Item] the updated item (a fresh frozen +Data+
92
115
  # instance — the old one is replaced in place).
93
116
  # @raise [ItemNotFound] if no matching item exists.
94
117
  # @raise [ArgumentError] if +status+ is not in {STATUSES}.
95
- def set_status(content:, status:)
118
+ def set_status(id:, status:)
96
119
  unless STATUSES.include?(status)
97
120
  raise ArgumentError, "invalid status: #{status.inspect} (allowed: #{STATUSES.join(', ')})"
98
121
  end
99
122
 
100
- idx = @items.index { |i| i.content == content }
101
- raise ItemNotFound, content if idx.nil?
123
+ idx = @items.index { |i| i.id == id }
124
+ raise ItemNotFound, id.to_s if idx.nil?
102
125
 
103
- @items[idx] = Item.new(content: content, status: status)
126
+ @items[idx] = @items[idx].with(status: status)
127
+ @on_change&.call
104
128
  @items[idx]
105
129
  end
106
130
 
107
- # Remove the item whose +content+ matches.
131
+ # Remove the item whose +id+ matches. The id is not reused for
132
+ # later items (see {#add}).
108
133
  #
109
- # @param content [String]
134
+ # @param id [Integer]
110
135
  # @return [Item] the removed item.
111
136
  # @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?
137
+ def delete(id)
138
+ idx = @items.index { |i| i.id == id }
139
+ raise ItemNotFound, id.to_s if idx.nil?
115
140
 
116
- @items.delete_at(idx)
141
+ removed = @items.delete_at(idx)
142
+ @on_change&.call
143
+ removed
117
144
  end
118
145
 
119
146
  # 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:
147
+ # task tool, so the LLM sees the latest full state including
148
+ # each item's id — on each call without needing a separate read
149
+ # tool. Format:
122
150
  #
123
151
  # <tasks>
124
- # - [pending] Add dark mode toggle
125
- # - [in_progress] Write unit tests
126
- # - [completed] Update README
152
+ # - #1 [pending] Add dark mode toggle
153
+ # - #2 [in_progress] Write unit tests
154
+ # - #3 [completed] Update README
127
155
  # </tasks>
128
156
  #
129
157
  # Empty list renders as +<tasks>(empty)</tasks>+ so the LLM gets
@@ -134,15 +162,9 @@ module Pikuri
134
162
  def render
135
163
  return '<tasks>(empty)</tasks>' if @items.empty?
136
164
 
137
- lines = @items.map { |i| "- [#{i.status}] #{i.content}" }
165
+ lines = @items.map { |i| "- ##{i.id} [#{i.status}] #{i.content}" }
138
166
  "<tasks>\n#{lines.join("\n")}\n</tasks>"
139
167
  end
140
-
141
- private
142
-
143
- def find(content)
144
- @items.find { |i| i.content == content }
145
- end
146
168
  end
147
169
  end
148
170
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Tasks
5
+ # Domain event emitted onto the agent's listener stream after
6
+ # every {List} mutation (add / status change / delete), wired by
7
+ # {Extension#bind} via
8
+ # {Pikuri::Agent::ExtensionContext#emit_event}. Carries a frozen
9
+ # point-in-time snapshot of the whole list ({List#items}'s
10
+ # shape), so a consumer needs no +List+ reference — render or
11
+ # serialize the payload as-is.
12
+ #
13
+ # Lands between the mutating tool's {Pikuri::Agent::Event::ToolCall}
14
+ # and {Pikuri::Agent::Event::ToolResult} in the stream (the
15
+ # +on_change+ hook fires inside the tool's +execute+). A batch
16
+ # +task_create+ emits one event per added item — consumers that
17
+ # render should treat the latest snapshot as authoritative
18
+ # (last-wins) rather than diffing event-by-event.
19
+ #
20
+ # Fired on the agent's thread. A listener feeding another thread
21
+ # (e.g. a web UI pushing over SSE) should serialize inside
22
+ # +on_event+ and hand off only the immutable result — see the
23
+ # Concurrency note on {List}.
24
+ ListChanged = Data.define(:items)
25
+ end
26
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pikuri-tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Vysny
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-04 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: pikuri-core
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - '='
18
17
  - !ruby/object:Gem::Version
19
- version: 0.0.6
18
+ version: 0.0.7
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - '='
25
24
  - !ruby/object:Gem::Version
26
- version: 0.0.6
25
+ version: 0.0.7
27
26
  description: |
28
27
  pikuri-tasks gives a pikuri-core agent an in-memory task list it
29
28
  can use to plan and track multi-step work. A +Pikuri::Tasks::List+
@@ -51,6 +50,7 @@ files:
51
50
  - lib/pikuri/tasks/extension.rb
52
51
  - lib/pikuri/tasks/in_progress.rb
53
52
  - lib/pikuri/tasks/list.rb
53
+ - lib/pikuri/tasks/list_changed.rb
54
54
  homepage: https://codeberg.org/mvysny/pikuri
55
55
  licenses:
56
56
  - MIT
@@ -59,7 +59,6 @@ metadata:
59
59
  changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
60
60
  bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
61
61
  rubygems_mfa_required: 'true'
62
- post_install_message:
63
62
  rdoc_options: []
64
63
  require_paths:
65
64
  - lib
@@ -74,8 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
74
73
  - !ruby/object:Gem::Version
75
74
  version: '0'
76
75
  requirements: []
77
- rubygems_version: 3.5.22
78
- signing_key:
76
+ rubygems_version: 3.6.7
79
77
  specification_version: 4
80
78
  summary: Per-session in-memory task list + tools for pikuri.
81
79
  test_files: []