pikuri-tasks 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ceab6f3f6999fb22176982adb511db13d445cba7044a435492071f2308f4eae
4
- data.tar.gz: da032f405772ada2943df18113c1b4670799003069c5f329676d4bdc765d4397
3
+ metadata.gz: bef39f17faad60151665971aac68c2090ca08d02e343ce209ca00cdc81e4e7c5
4
+ data.tar.gz: bc0e54db37e868a58015c8ff18f0a9aba2a7e1232310faa608fcd4e525e2aea8
5
5
  SHA512:
6
- metadata.gz: 7e74049b277b94b546f6e7a6a237e0e595aee40c7bd78fb7029bdb75c9eca36d52144ea97cd71f7215dfcf7a1dbea8c8270c6ef6f8125bd256b27c413d8a2566
7
- data.tar.gz: 8a46f113b26b1014b34b434fe8b2a79eccef7c2a587dc4ffff8ead471b96ebbf5f8c4cb642934938ef244b39cc5cde6ea4f6ac9a0d272322edbccff05c5a5e24
6
+ metadata.gz: 6583cca82b6b2b8ac13169ce045420cb960a06157a2cc2f4da7f6ebbbbb232066ae58d9b484c4736bc509b4c78625265a5b84838cd44e9cbe2dbd94c5575fbb0
7
+ data.tar.gz: b778189760eb74d9ee7d882da3b308ed4bd8a020d21c2f480e6f2cc76b1868b64ea907e24f5ce132b688b14c09fcd38e8237e8825bf0a02398c31d99e077ad0e
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,23 @@
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 as {InProgress} (status in the tool name,
7
+ # item addressed by numeric +#id+). Returns {List#render}; a bad id
8
+ # returns +"Error: no such task id"+ plus the current list.
10
9
  #
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.
10
+ # Sharing: +P_one_agent+ closes over one agent's {List}; see its
11
+ # +== Sharing+.
14
12
  class Completed < Pikuri::Tool
15
13
  # @return [String]
16
14
  DESCRIPTION = <<~DESC
17
15
  Mark a task as `completed` once the work — including any required verification — is actually done.
18
16
 
19
17
  Usage:
20
- - Pass the exact `content` string the task was created with.
18
+ - Pass the task's numeric `id` as shown in the rendered list (`- #3 [in_progress] ...` → id 3).
21
19
  - Do NOT mark `completed` based on intent; mark it only after the underlying work is verified.
22
20
  - 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.
21
+ - On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
24
22
  - On success the full current list is returned for you to read back.
25
23
  DESC
26
24
 
@@ -31,24 +29,26 @@ module Pikuri
31
29
  name: 'task_completed',
32
30
  description: DESCRIPTION,
33
31
  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".'
32
+ p.required_integer :id,
33
+ 'Numeric id of the existing task to mark as ' \
34
+ 'completed, as shown in the rendered list, e.g. 3.'
37
35
  },
38
- execute: lambda { |content:|
39
- Completed.execute(list: list, content: content)
40
- }
36
+ execute: lambda { |id:|
37
+ Completed.execute(list: list, id: id)
38
+ },
39
+ # No legs; see {Create}.
40
+ trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
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
@@ -2,23 +2,22 @@
2
2
 
3
3
  module Pikuri
4
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.
5
+ # The +task_create+ tool: mass-create pending items from a JSON array
6
+ # of strings a native +items+ array parameter, the shape the model's
7
+ # training prior produces unprompted. (Regression guard: don't revert
8
+ # to a newline-separated string. An earlier such design meant to spare
9
+ # small models the bracket-balancing, but models sent JSON arrays
10
+ # anyway and the splitter turned `[`, `"foo",`, `]` into garbage tasks
11
+ # match the prior, don't fight it.)
12
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.
13
+ # Atomic: each element is stripped, and a blank or duplicate element
14
+ # (within the batch or already in the list) aborts the *whole* call
15
+ # with an +"Error: ..."+ string and adds nothing — the LLM resends a
16
+ # corrected batch, and never has to reconcile a half-applied list. On
17
+ # success returns {List#render}.
19
18
  #
20
- # On success returns the rendered current list via {List#render},
21
- # so the LLM always sees fresh state without a separate read tool.
19
+ # Sharing: +P_one_agent+ closes over one agent's {List}; see its
20
+ # +== Sharing+.
22
21
  class Create < Pikuri::Tool
23
22
  # @return [String] static description shown to the LLM,
24
23
  # opencode-shape (summary + +Usage:+ bullets).
@@ -27,42 +26,43 @@ module Pikuri
27
26
 
28
27
  Usage:
29
28
  - 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.
29
+ - 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.
30
+ - 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
31
  DESC
35
32
 
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.
33
+ # @param list [List] the shared per-Agent list, captured by closure
34
+ # so every task tool mutates the same instance.
39
35
  # @return [Create]
40
36
  def initialize(list:)
41
37
  super(
42
38
  name: 'task_create',
43
39
  description: DESCRIPTION,
44
40
  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.'
41
+ p.required_string_array :items,
42
+ 'Task contents, one task per array element, e.g. ' \
43
+ '["Add dark mode toggle", "Write unit tests", "Update README"].'
49
44
  },
50
45
  execute: lambda { |items:|
51
46
  Create.execute(list: list, items: items)
52
- }
47
+ },
48
+ # No legs: an in-memory, per-agent list. Nothing read from disk, nothing sent.
49
+ trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
53
50
  )
54
51
  end
55
52
 
56
- # Validate and apply the batch. Public so specs can drive it
57
- # without constructing a tool instance.
53
+ # Validate and apply the batch.
58
54
  #
59
55
  # @param list [List]
60
- # @param items [String] raw +items+ argument from the LLM.
56
+ # @param items [Array<String>] raw +items+ argument from the LLM
57
+ # (already type-validated by {Pikuri::Tool::Parameters}).
61
58
  # @return [String] either {List#render} on success or an
62
59
  # +"Error: ..."+ string the LLM can react to.
63
60
  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?
61
+ return 'Error: task_create requires at least one item' if items.empty?
62
+
63
+ cleaned = items.map(&:strip)
64
+ blank = cleaned.index('')
65
+ return "Error: blank item at index #{blank} — every element must be non-blank task text" if blank
66
66
 
67
67
  seen_in_batch = {}
68
68
  cleaned.each do |c|
@@ -2,29 +2,24 @@
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+.
6
+ # Distinct from {Completed} in intent +completed+ means the work was
7
+ # done, +delete+ means the task shouldn't have been there a
8
+ # distinction the tool names carry, not the list. A deleted id is never
9
+ # reused ({List#add}). Returns {List#render}; a bad id returns
10
+ # +"Error: no such task id"+ plus the current list.
9
11
  #
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.
12
+ # Sharing: +P_one_agent+ closes over one agent's {List}; see its
13
+ # +== Sharing+.
19
14
  class Delete < Pikuri::Tool
20
15
  # @return [String]
21
16
  DESCRIPTION = <<~DESC
22
17
  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
18
 
24
19
  Usage:
25
- - Pass the exact `content` string the task was created with.
20
+ - Pass the task's numeric `id` as shown in the rendered list (`- #3 [pending] ...` → id 3).
26
21
  - 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.
22
+ - On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
28
23
  - On success the full current list is returned for you to read back.
29
24
  DESC
30
25
 
@@ -35,24 +30,26 @@ module Pikuri
35
30
  name: 'task_delete',
36
31
  description: DESCRIPTION,
37
32
  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".'
33
+ p.required_integer :id,
34
+ 'Numeric id of the existing task to remove, ' \
35
+ 'as shown in the rendered list, e.g. 3.'
36
+ },
37
+ execute: lambda { |id:|
38
+ Delete.execute(list: list, id: id)
41
39
  },
42
- execute: lambda { |content:|
43
- Delete.execute(list: list, content: content)
44
- }
40
+ # No legs; see {Create}.
41
+ trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
45
42
  )
46
43
  end
47
44
 
48
45
  # @param list [List]
49
- # @param content [String]
46
+ # @param id [Integer]
50
47
  # @return [String]
51
- def self.execute(list:, content:)
52
- list.delete(content)
48
+ def self.execute(list:, id:)
49
+ list.delete(id)
53
50
  list.render
54
51
  rescue ItemNotFound
55
- "Error: no such task: '#{content}'"
52
+ "Error: no such task id: #{id}. Current list:\n#{list.render}"
56
53
  end
57
54
  end
58
55
  end
@@ -3,47 +3,28 @@
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
- # 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
10
+ # An {Pikuri::Agent::Extension} that auto-wires an in-memory task list
11
+ # onto an agent: constructs a fresh {List}, registers the four task
12
+ # tools against it, contributes a workflow snippet, and (in {#bind})
13
+ # arms {ListChanged} emission.
15
14
  #
16
15
  # Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
17
16
  # c.add_extension Pikuri::Tasks::Extension.new
18
17
  # end
19
18
  #
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.
19
+ # Sub-agents don't inherit extensions, so a spawned sub-agent gets no
20
+ # task listkeeping the parent's plan private and avoiding
21
+ # who-owns-which-task confusion. A host wanting one adds the extension
22
+ # to the sub-agent's own configurator.
39
23
  class Extension
40
24
  include Pikuri::Agent::Extension
41
25
 
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.
26
+ # System-prompt snippet appended once per agent. Rules-of-thumb
27
+ # only, no inventory (the tool descriptions cover their own usage).
47
28
  #
48
29
  # @return [String]
49
30
  PROMPT_SNIPPET = <<~PROMPT
@@ -51,19 +32,18 @@ module Pikuri
51
32
  You have an in-memory task list. Use it to plan and track multi-step work.
52
33
 
53
34
  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.
35
+ - When a task has 3+ steps, call `task_create` once with the full plan (a JSON array of strings, all start as `pending`).
36
+ - Before starting an item, call `task_in_progress` with its numeric id. Keep exactly one item `in_progress` at a time.
37
+ - When an item is fully done (including any required verification), call `task_completed` with its numeric id.
57
38
  - Use `task_delete` to remove items that turn out not to be needed.
58
39
 
59
40
  Skip task tracking entirely for single-step or purely informational requests — it adds noise, not value.
60
41
 
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.
42
+ 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
43
  </tasks_usage>
63
44
  PROMPT
64
45
 
65
- # Tool classes the extension auto-registers. Used both for
66
- # construction and for the duplicate-registration guard below.
46
+ # Tool classes the extension auto-registers.
67
47
  TOOL_CLASSES = [Create, InProgress, Completed, Delete].freeze
68
48
 
69
49
  # @return [Extension]
@@ -71,17 +51,16 @@ module Pikuri
71
51
  @list = List.new
72
52
  end
73
53
 
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).
54
+ # @return [List] the per-agent list, exposed for tests. Do NOT read
55
+ # it from another thread it's agent-thread-confined; consume the
56
+ # {ListChanged} events wired by {#bind} instead.
77
57
  attr_reader :list
78
58
 
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.
59
+ # Construct the four tools (each sharing +@list+), register them,
60
+ # and append {PROMPT_SNIPPET}. Raises if any of the four were
61
+ # pre-registered via +c.add_tool+ — the extension is the single
62
+ # owner of the shared list, and a pre-registered tool would bind to
63
+ # a different one.
85
64
  #
86
65
  # @param c [Pikuri::Agent::Configurator]
87
66
  # @return [void]
@@ -95,7 +74,33 @@ module Pikuri
95
74
  end
96
75
 
97
76
  TOOL_CLASSES.each { |cls| c.add_tool(cls.new(list: @list)) }
98
- c.append_system_prompt(PROMPT_SNIPPET)
77
+ nil
78
+ end
79
+
80
+ # @return [Array<String>] the static task-workflow snippet.
81
+ def system_prompt_snippets = [PROMPT_SNIPPET]
82
+
83
+ # Arm {List#on_change} to emit a {ListChanged} (carrying a fresh
84
+ # {List#items} snapshot) after every mutation — how a UI listener
85
+ # observes the list without touching the agent-thread-confined {List}.
86
+ #
87
+ # @param ctx [Pikuri::Agent::ExtensionContext]
88
+ # @return [void]
89
+ def bind(ctx)
90
+ @list.on_change = -> { ctx.emit_event(ListChanged.new(items: @list.items)) }
91
+ nil
92
+ end
93
+
94
+ # Clear the task list on conversation reset — a plan is meaningless
95
+ # without the conversation that produced it. {List#clear} fires the
96
+ # armed +on_change+, so an empty {ListChanged} reaches the listener
97
+ # stream like any other mutation.
98
+ #
99
+ # @param ctx [Pikuri::Agent::ExtensionContext] unused; part of the
100
+ # protocol signature.
101
+ # @return [void]
102
+ def on_conversation_reset(_ctx)
103
+ @list.clear
99
104
  nil
100
105
  end
101
106
  end
@@ -2,26 +2,28 @@
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+ as
6
+ # +in_progress+. The status is baked into the tool name, not a
7
+ # parameter one fewer degree of freedom for the LLM (no
8
+ # +"in-progress"+/+"inprogress"+ typos) at the cost of one tool class.
11
9
  #
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.
10
+ # Items are addressed by numeric +#id+, not content string (regression
11
+ # guard: an earlier content-as-id design locked the LLM out of its own
12
+ # list on one near-miss byte a stray quote or trailing comma; an id
13
+ # is two digits it just read back). Returns {List#render}; a bad id
14
+ # returns +"Error: no such task id"+ plus the current list.
15
+ #
16
+ # Sharing: +P_one_agent+ — closes over one agent's {List}; see its
17
+ # +== Sharing+.
16
18
  class InProgress < Pikuri::Tool
17
19
  # @return [String]
18
20
  DESCRIPTION = <<~DESC
19
21
  Mark a task as `in_progress` immediately before you start working on it.
20
22
 
21
23
  Usage:
22
- - Pass the exact `content` string the task was created with content doubles as identifier; spelling and capitalization must match.
24
+ - Pass the task's numeric `id` as shown in the rendered list (`- #3 [pending] ...` id 3).
23
25
  - 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.
26
+ - On `Error: no such task id: ...` the call did nothing — the error includes the current list; pick the right id from it.
25
27
  - On success the full current list is returned for you to read back.
26
28
  DESC
27
29
 
@@ -32,24 +34,26 @@ module Pikuri
32
34
  name: 'task_in_progress',
33
35
  description: DESCRIPTION,
34
36
  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".'
37
+ p.required_integer :id,
38
+ 'Numeric id of the existing task to mark as ' \
39
+ 'in_progress, as shown in the rendered list, e.g. 3.'
40
+ },
41
+ execute: lambda { |id:|
42
+ InProgress.execute(list: list, id: id)
38
43
  },
39
- execute: lambda { |content:|
40
- InProgress.execute(list: list, content: content)
41
- }
44
+ # No legs; see {Create}.
45
+ trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
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
@@ -2,54 +2,60 @@
2
2
 
3
3
  module Pikuri
4
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.
5
+ # Raised by {List#add} when a new item's +content+ already appears in
6
+ # the list rejected because a duplicate is almost always an LLM
7
+ # mistake (re-sending an already-captured plan). Carries the offending
8
+ # content for the +"Error: ..."+ observation.
11
9
  class DuplicateItem < StandardError; end
12
10
 
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.
11
+ # Raised by {List#set_status} and {List#delete} when no item with the
12
+ # given +id+ exists; the mutation tools rescue it into an +"Error: no
13
+ # such task id"+ observation.
17
14
  class ItemNotFound < StandardError; end
18
15
 
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.
16
+ # Allowed values for {Item#status}. Deliberately threethe workflow
17
+ # the prompt advertises (pending → in_progress → completed) — with
18
+ # +task_delete+ removing items outright rather than adding a fourth
19
+ # +cancelled+/+deleted+ status.
24
20
  STATUSES = %w[pending in_progress completed].freeze
25
21
 
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)
22
+ # One row in a {List}. +id+ is the numeric identifier the mutation
23
+ # tools address the item by (assigned by {List#add}, never reused);
24
+ # +content+ is the LLM-visible task text; +status+ is one of {STATUSES}.
25
+ Item = Data.define(:id, :content, :status)
30
26
 
31
27
  # 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.
28
+ # {Pikuri::Agent}, shared by closure into all four task tools so every
29
+ # mutation hits the same instance.
35
30
  #
36
- # == Concurrency
31
+ # Not persisted: dropped when the +Agent+ is collected, matching the
32
+ # gem's "in-memory only" scope.
37
33
  #
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.
34
+ # == Sharing
41
35
  #
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."
36
+ # +P_one_agent+ — this is one conversation's plan, so two agents on one
37
+ # list would read and complete each other's tasks. Unguarded, and it needs
38
+ # no lock because its only caller is the agent whose tool calls are already
39
+ # serialized. Another thread (a UI rendering the list) must not touch a
40
+ # +List+ directly: it consumes the {ListChanged} events {Extension#bind}
41
+ # wires onto the listener stream, whose +items+ payload is an immutable
42
+ # snapshot safe to hand across threads.
47
43
  class List
48
44
  # @return [List]
49
45
  def initialize
50
46
  @items = []
47
+ @next_id = 1
48
+ @on_change = nil
51
49
  end
52
50
 
51
+ # Optional zero-argument hook invoked synchronously after every
52
+ # *successful* mutation (a raise means nothing changed, so no
53
+ # notify). Set by {Extension#bind} to emit a {ListChanged}; +nil+
54
+ # (default) disables notification.
55
+ #
56
+ # @return [Proc, nil]
57
+ attr_accessor :on_change
58
+
53
59
  # @return [Array<Item>] a frozen snapshot of the current items,
54
60
  # in insertion order. Callers cannot mutate the internal
55
61
  # storage through this accessor.
@@ -67,82 +73,95 @@ module Pikuri
67
73
  @items.empty?
68
74
  end
69
75
 
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.
76
+ # Append a new item with status +pending+ and the next id from a
77
+ # monotonic per-list counter. Ids are never reused: after a
78
+ # delete, the freed id stays dead, so a stale id held by the LLM
79
+ # errors loudly instead of silently resolving to a newer task.
73
80
  #
74
81
  # @param content [String] non-empty content; whitespace is the
75
82
  # caller's responsibility.
76
83
  # @return [Item] the newly added item
77
- # @raise [DuplicateItem] if an item with the same +content+
78
- # already exists.
84
+ # @raise [DuplicateItem] if an item with the same +content+ exists.
79
85
  def add(content)
80
- raise DuplicateItem, content if find(content)
86
+ raise DuplicateItem, content if @items.any? { |i| i.content == content }
81
87
 
82
- item = Item.new(content: content, status: 'pending')
88
+ item = Item.new(id: @next_id, content: content, status: 'pending')
89
+ @next_id += 1
83
90
  @items << item
91
+ @on_change&.call
84
92
  item
85
93
  end
86
94
 
87
- # Update the status of the item whose +content+ matches.
95
+ # Update the status of the item whose +id+ matches.
88
96
  #
89
- # @param content [String]
97
+ # @param id [Integer]
90
98
  # @param status [String] one of {STATUSES}.
91
99
  # @return [Item] the updated item (a fresh frozen +Data+
92
100
  # instance — the old one is replaced in place).
93
101
  # @raise [ItemNotFound] if no matching item exists.
94
102
  # @raise [ArgumentError] if +status+ is not in {STATUSES}.
95
- def set_status(content:, status:)
103
+ def set_status(id:, status:)
96
104
  unless STATUSES.include?(status)
97
105
  raise ArgumentError, "invalid status: #{status.inspect} (allowed: #{STATUSES.join(', ')})"
98
106
  end
99
107
 
100
- idx = @items.index { |i| i.content == content }
101
- raise ItemNotFound, content if idx.nil?
108
+ idx = @items.index { |i| i.id == id }
109
+ raise ItemNotFound, id.to_s if idx.nil?
102
110
 
103
- @items[idx] = Item.new(content: content, status: status)
111
+ @items[idx] = @items[idx].with(status: status)
112
+ @on_change&.call
104
113
  @items[idx]
105
114
  end
106
115
 
107
- # Remove the item whose +content+ matches.
116
+ # Remove the item whose +id+ matches. The id is not reused for
117
+ # later items (see {#add}).
108
118
  #
109
- # @param content [String]
119
+ # @param id [Integer]
110
120
  # @return [Item] the removed item.
111
121
  # @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?
122
+ def delete(id)
123
+ idx = @items.index { |i| i.id == id }
124
+ raise ItemNotFound, id.to_s if idx.nil?
125
+
126
+ removed = @items.delete_at(idx)
127
+ @on_change&.call
128
+ removed
129
+ end
115
130
 
116
- @items.delete_at(idx)
131
+ # Drop every item and rewind the id counter to 1, then fire
132
+ # {#on_change} (always, even when already empty, so a UI listener
133
+ # sees the cleared state). Rewinding +@next_id+ is safe here — unlike
134
+ # after {#delete}, a clear wipes the model's context too, so no stale
135
+ # id survives to collide with a reused one.
136
+ #
137
+ # @return [void]
138
+ def clear
139
+ @items = []
140
+ @next_id = 1
141
+ @on_change&.call
142
+ nil
117
143
  end
118
144
 
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:
145
+ # The canonical rendering every task tool returns as its
146
+ # observation, so the LLM sees the full state (ids included) each
147
+ # call without a separate read tool:
122
148
  #
123
149
  # <tasks>
124
- # - [pending] Add dark mode toggle
125
- # - [in_progress] Write unit tests
126
- # - [completed] Update README
150
+ # - #1 [pending] Add dark mode toggle
151
+ # - #2 [in_progress] Write unit tests
152
+ # - #3 [completed] Update README
127
153
  # </tasks>
128
154
  #
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.
155
+ # Empty renders as +<tasks>(empty)</tasks>+ an unambiguous "the
156
+ # call worked, list is now empty" rather than a blank block.
132
157
  #
133
158
  # @return [String]
134
159
  def render
135
160
  return '<tasks>(empty)</tasks>' if @items.empty?
136
161
 
137
- lines = @items.map { |i| "- [#{i.status}] #{i.content}" }
162
+ lines = @items.map { |i| "- ##{i.id} [#{i.status}] #{i.content}" }
138
163
  "<tasks>\n#{lines.join("\n")}\n</tasks>"
139
164
  end
140
-
141
- private
142
-
143
- def find(content)
144
- @items.find { |i| i.content == content }
145
- end
146
165
  end
147
166
  end
148
167
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Tasks
5
+ # Domain event emitted after every {List} mutation (add / status
6
+ # change / delete), wired by {Extension#bind} via
7
+ # {Pikuri::Agent::ExtensionContext#emit_event}. Carries a frozen
8
+ # whole-list snapshot ({List#items}), so a consumer needs no +List+
9
+ # reference — render or serialize the payload as-is.
10
+ #
11
+ # A batch +task_create+ emits one event per added item, so a renderer
12
+ # should treat the latest snapshot as authoritative (last-wins), not
13
+ # diff event-by-event. Fired on the agent's thread — a listener
14
+ # feeding another thread serializes inside +on_event+ and hands off
15
+ # only the immutable result (see {List}'s thread-confinement note).
16
+ ListChanged = Data.define(:items)
17
+ end
18
+ end
data/lib/pikuri-tasks.rb CHANGED
@@ -2,16 +2,8 @@
2
2
 
3
3
  require 'pikuri-core'
4
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.
5
+ # Entry file for the pikuri-tasks gem: sets up the per-gem Zeitwerk
6
+ # loader. See {Pikuri::Tasks} for the namespace overview.
15
7
  module Pikuri
16
8
  module Tasks
17
9
  LOADER = Zeitwerk::Loader.new
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.1.0
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.1.0
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.1.0
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: []