@zhuxixi/pi-agent-board 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ # Question/Questionnaire Tool Grouping Fix Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make pi sessions blocked on the `question`/`questionnaire` tools group under "Needs answer" instead of "Running".
6
+
7
+ **Architecture:** Two minimal changes to `src/core/events.mjs`: (1) extend the private `questionFromArgs` helper to read pi's arg shapes; (2) replace the hardcoded `=== "ask_questions"` name checks with a `QUESTION_TOOL_NAMES` set. All downstream behavior (needs_input state, grouping, summary) already exists via `preservePendingQuestion` and `deriveSummary` — verified in spec section 3.3, nothing else changes.
8
+
9
+ **Tech Stack:** Node.js (`.mjs` ESM, no deps), `node:test` + `node:assert/strict` for tests.
10
+
11
+ ## Global Constraints
12
+
13
+ - All code changes confined to `src/core/events.mjs` and `test/events.test.mjs`.
14
+ - No store/schema/type changes; no changes to `finalizeRun`, `projectViewState`, `deriveSummary`, `rows.mjs`, `service.mjs`.
15
+ - Non-interactive (detached) reduction path keeps its current behavior — the `opts.interactive` gate stays.
16
+ - Static name set, no config surface.
17
+ - Commit messages in conventional commits format; stage files individually (`git add <file>`), never `git add -A`.
18
+ - Test command: `node --test test/events.test.mjs`; project gate: `npm run verify`.
19
+ - Work in worktree `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-26-question-tool-grouping`; never touch main.
20
+
21
+ ---
22
+
23
+ ### Task 1: Extend `questionFromArgs` for pi arg shapes
24
+
25
+ **Files:**
26
+ - Modify: `src/core/events.mjs:229-235`
27
+ - Test: `test/events.test.mjs` (append new test near the ask_questions tests, after line 160)
28
+
29
+ **Interfaces:**
30
+ - Consumes: nothing new.
31
+ - Produces: `questionFromArgs(args)` (private) returns the first non-empty question text from: `args.question` (string), or `args.questions[]` items' `question` or `prompt` fields; falls back to `"Answer the pending question"`. Task 2's name-recognition relies on this extraction.
32
+
33
+ - [ ] **Step 1: Write the failing test**
34
+
35
+ Append to `test/events.test.mjs` (after the "interactive questions remain visible..." test):
36
+
37
+ ```js
38
+ test("questionFromArgs extracts pi question/questionnaire arg shapes", () => {
39
+ // pi `question` tool shape: args.question is a plain string.
40
+ const s1 = createRunStatus(cfg(), 1, 1000);
41
+ reduceEvent(s1, {
42
+ type: "tool_execution_start",
43
+ toolCallId: "q1",
44
+ toolName: "ask_questions",
45
+ args: { question: "Approve the plan?" },
46
+ }, 2000, { interactive: true });
47
+ assert.equal(s1.question, "Approve the plan?");
48
+
49
+ // pi `questionnaire` tool shape: args.questions[].prompt.
50
+ const s2 = createRunStatus(cfg(), 1, 1000);
51
+ reduceEvent(s2, {
52
+ type: "tool_execution_start",
53
+ toolCallId: "q1",
54
+ toolName: "ask_questions",
55
+ args: { questions: [{ prompt: "Pick the scope?" }] },
56
+ }, 2000, { interactive: true });
57
+ assert.equal(s2.question, "Pick the scope?");
58
+ });
59
+ ```
60
+
61
+ Note: the tests route through the reducer with the already-recognized `ask_questions` name because `questionFromArgs` is private and the reducer is the public surface; name recognition for the pi tools lands in Task 2.
62
+
63
+ - [ ] **Step 2: Run test to verify it fails**
64
+
65
+ Run: `node --test test/events.test.mjs`
66
+ Expected: FAIL — both assertions get `"Answer the pending question"` (the current extractor only reads `questions[].question`).
67
+
68
+ - [ ] **Step 3: Implement the extractor**
69
+
70
+ Replace the body of `questionFromArgs` in `src/core/events.mjs` (L229-235):
71
+
72
+ ```js
73
+ function questionFromArgs(args) {
74
+ if (typeof args?.question === "string" && args.question.trim()) return args.question.trim();
75
+ for (const item of Array.isArray(args?.questions) ? args.questions : []) {
76
+ const question = String(item?.question ?? item?.prompt ?? "").trim();
77
+ if (question) return question;
78
+ }
79
+ return "Answer the pending question";
80
+ }
81
+ ```
82
+
83
+ - [ ] **Step 4: Run tests to verify they pass**
84
+
85
+ Run: `node --test test/events.test.mjs`
86
+ Expected: PASS (all 17 existing + 1 new test).
87
+
88
+ - [ ] **Step 5: Commit**
89
+
90
+ ```bash
91
+ git add src/core/events.mjs test/events.test.mjs
92
+ git commit -m "fix: support pi question arg shapes in questionFromArgs (issue #26)"
93
+ ```
94
+
95
+ ---
96
+
97
+ ### Task 2: Recognize `question`/`questionnaire` as pending-question tools
98
+
99
+ **Files:**
100
+ - Modify: `src/core/events.mjs:25-27` (insert set), `src/core/events.mjs:83`, `src/core/events.mjs:97`
101
+ - Test: `test/events.test.mjs` (append three tests after the Task 1 test)
102
+
103
+ **Interfaces:**
104
+ - Consumes: `questionFromArgs` extraction from Task 1.
105
+ - Produces: `QUESTION_TOOL_NAMES` (module-private `Set<string>`). `reduceEvent` treats any interactive tool_execution_start/end whose tool name is in the set as a pending-question event — `upsertPendingQuestion`/`removePendingQuestion` plus the existing `preservePendingQuestion` flow (needs_input state, `currentTool = null`).
106
+
107
+ - [ ] **Step 1: Write the failing tests**
108
+
109
+ Append to `test/events.test.mjs`:
110
+
111
+ ```js
112
+ test("interactive pi question tool is treated as a pending question", () => {
113
+ const s = createRunStatus(cfg(), 1, 1000);
114
+ reduceEvent(s, {
115
+ type: "tool_execution_start",
116
+ toolCallId: "q1",
117
+ toolName: "question",
118
+ args: { question: "Approve the plan?", options: [{ label: "Yes" }, { label: "No" }] },
119
+ }, 2000, { interactive: true });
120
+ assert.equal(s.semanticState, "needs_input");
121
+ assert.equal(s.question, "Approve the plan?");
122
+ assert.equal(s.currentTool, null);
123
+ assert.equal(s.summary, "Approve the plan?");
124
+ assert.deepEqual(s.pendingQuestions, [{ toolCallId: "q1", question: "Approve the plan?" }]);
125
+ assert.equal(projectViewState(s, 2100).needsInput, true);
126
+
127
+ reduceEvent(s, { type: "tool_execution_end", toolCallId: "q1", toolName: "question", isError: false }, 2400, { interactive: true });
128
+ assert.equal(s.semanticState, "working");
129
+ assert.equal(s.question, null);
130
+ assert.deepEqual(s.pendingQuestions, []);
131
+ });
132
+
133
+ test("interactive questionnaire tool extracts prompt and clears on end", () => {
134
+ const s = createRunStatus(cfg(), 1, 1000);
135
+ reduceEvent(s, {
136
+ type: "tool_execution_start",
137
+ toolCallId: "q1",
138
+ toolName: "questionnaire",
139
+ args: { questions: [{ prompt: "Pick the scope?" }] },
140
+ }, 2000, { interactive: true });
141
+ assert.equal(s.semanticState, "needs_input");
142
+ assert.equal(s.question, "Pick the scope?");
143
+ reduceEvent(s, { type: "tool_execution_end", toolCallId: "q1", toolName: "questionnaire", isError: false }, 2400, { interactive: true });
144
+ assert.equal(s.semanticState, "working");
145
+ });
146
+
147
+ test("detached question tool keeps legacy currentTool behavior", () => {
148
+ const s = createRunStatus(cfg(), 1, 1000);
149
+ reduceEvent(s, { type: "tool_execution_start", toolCallId: "q1", toolName: "question", args: { question: "Approve?" } }, 2000);
150
+ assert.equal(s.semanticState, "working");
151
+ assert.equal(s.currentTool.name, "question");
152
+ assert.deepEqual(s.pendingQuestions, []);
153
+ });
154
+ ```
155
+
156
+ - [ ] **Step 2: Run tests to verify they fail**
157
+
158
+ Run: `node --test test/events.test.mjs`
159
+ Expected: FAIL — question/questionnaire names are not recognized: first test asserts `needs_input` but gets `working` with `currentTool.name === "question"`.
160
+
161
+ - [ ] **Step 3: Add the name set and wire the two checks**
162
+
163
+ Insert after the imports at the top of `src/core/events.mjs` (before `createRunStatus`, ~L25):
164
+
165
+ ```js
166
+ /** Tool names whose interactive execution blocks on a user answer. */
167
+ const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
168
+ ```
169
+
170
+ Replace L83:
171
+
172
+ ```js
173
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(name)) {
174
+ ```
175
+
176
+ Replace L97:
177
+
178
+ ```js
179
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(event.toolName ?? "")) removePendingQuestion(status, event.toolCallId);
180
+ ```
181
+
182
+ - [ ] **Step 4: Run tests to verify they pass**
183
+
184
+ Run: `node --test test/events.test.mjs`
185
+ Expected: PASS (21 tests total: 17 existing + 1 Task 1 + 3 new). Existing `ask_questions` tests must pass unchanged.
186
+
187
+ - [ ] **Step 5: Full project gate**
188
+
189
+ Run: `npm run verify`
190
+ Expected: typecheck, all tests, and pack dry-run pass.
191
+
192
+ - [ ] **Step 6: Commit**
193
+
194
+ ```bash
195
+ git add src/core/events.mjs test/events.test.mjs
196
+ git commit -m "fix: recognize question/questionnaire tools as pending questions (issue #26)"
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Self-Review
202
+
203
+ **Spec coverage:**
204
+ - Spec 3.1 (QUESTION_TOOL_NAMES replacing both hardcoded checks) → Task 2 steps 3. ✓
205
+ - Spec 3.2 (questionFromArgs pi shapes: `args.question`, `items[].prompt`, fallback `item.question`) → Task 1 step 3. ✓
206
+ - Spec 3.4 tests 1-4 (question start→needs_input, questionnaire extraction, end→working, detached unchanged) → Task 2 step 1 + Task 1 step 1. ✓
207
+ - Spec 3.3 downstream flow unchanged → asserted via `summary`/`needsInput` in Task 2 test 1; no production code touched outside `events.mjs`. ✓
208
+
209
+ **Placeholder scan:** every step carries concrete code or exact commands; no TBD/TODO/"similar to". ✓
210
+
211
+ **Type consistency:** `QUESTION_TOOL_NAMES` used identically in both replace steps; arg fields (`question`, `questions[].prompt`) match the pi extension schemas (`~/.pi/agent/extensions/question.ts`, `questionnaire.ts`). ✓
@@ -0,0 +1,114 @@
1
+ # Spec: Recognize pi `question`/`questionnaire` tools as pending-question sources
2
+
3
+ - **Issue**: zhuxixi/pi-agent-board#26
4
+ - **Date**: 2026-08-22
5
+ - **Status**: draft — awaiting user confirmation before implementation
6
+ - **Type**: bug fix (event reducer + arg extraction), no data-model change
7
+
8
+ ## 1. Problem
9
+
10
+ A pi session blocked on the built-in `question` (or `questionnaire`) tool shows a
11
+ "Question" summary but stays in the **RUNNING** group instead of **NEEDS ANSWER**.
12
+
13
+ Observed 2026-08-22 01:26 on view `view_e809b1a3a2` ("jfox moc密度PR审查与监控"):
14
+ row summary "Question", `semanticState=working`, `pendingQuestions=[]`.
15
+
16
+ ## 2. Root cause
17
+
18
+ The reducer only recognizes ONE question-tool name — `ask_questions`
19
+ (`src/core/events.mjs` L83/L97). Pi's actual question tools are named
20
+ `question` and `questionnaire` (user extensions:
21
+ `~/.pi/agent/extensions/question.ts`, `questionnaire.ts`).
22
+
23
+ When `tool_execution_start` arrives with `toolName: "question"`:
24
+ 1. Name check fails → `else if (pendingQuestions.length === 0)` branch runs →
25
+ `semanticState = "working"`, `currentTool.summary = toolSummary("question")` =
26
+ `capitalize("question")` = `"Question"` (heuristics.mjs default branch).
27
+ 2. `preservePendingQuestion` finds no pending question → no-op.
28
+ 3. Result: row grouped by `semanticState` = "working" → RUNNING, summary "Question".
29
+
30
+ Correction to issue body: the "design gap B" claim (pending questions don't affect
31
+ grouping) is **wrong** — `preservePendingQuestion` already sets
32
+ `semanticState = "needs_input"` when `pendingQuestions` is non-empty. The ONLY gap is
33
+ tool-name recognition (plus arg-shape extraction). See research/root-cause.md.
34
+
35
+ ## 3. Design
36
+
37
+ All changes in `src/core/events.mjs` + tests. No store/schema/type changes.
38
+
39
+ ### 3.1 Question-tool name set
40
+
41
+ ```js
42
+ const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
43
+ ```
44
+
45
+ Replace both `name === "ask_questions"` checks (tool_execution_start L83,
46
+ tool_execution_end L97) with `QUESTION_TOOL_NAMES.has(name)` /
47
+ `QUESTION_TOOL_NAMES.has(event.toolName ?? "")`.
48
+
49
+ Rationale: static set matches the existing hardcoded-name precedent; all three names
50
+ are in the family of pi/agent question tools. A configurable list is deferred (4.2).
51
+
52
+ ### 3.2 `questionFromArgs` — support pi arg shapes
53
+
54
+ Current: reads `args.questions[].question` (ask_questions shape).
55
+
56
+ Extend to:
57
+
58
+ | Tool | Args shape | Extract from |
59
+ |---|---|---|
60
+ | `ask_questions` | `{ questions: [{ question }] }` | `item.question` |
61
+ | `question` (pi) | `{ question: string, options: [...] }` | `args.question` |
62
+ | `questionnaire` (pi) | `{ questions: [{ prompt, options }] }` | `item.prompt`, fallback `item.question` |
63
+
64
+ Priority in `questions[]` items: `item.question ?? item.prompt`. Keep the
65
+ "Answer the pending question" fallback.
66
+
67
+ ### 3.3 Downstream flow (already correct — verify, don't change)
68
+
69
+ - `tool_execution_start` (interactive, question tool): `upsertPendingQuestion` →
70
+ `preservePendingQuestion` → `semanticState = "needs_input"`, `currentTool = null`,
71
+ `question = first.question`. Row moves to NEEDS ANSWER group; summary shows the
72
+ question text (deriveSummary: needs_input + question).
73
+ - `tool_execution_end` (interactive, question tool): `removePendingQuestion`,
74
+ `semanticState = "working"`, `question = null` — back to RUNNING after the answer.
75
+ - Non-interactive (detached worker) path: `opts.interactive` gate unchanged →
76
+ question tools keep the old generic behavior (no pending-question tracking).
77
+
78
+ ### 3.4 Tests (`test/events.test.mjs`)
79
+
80
+ Mirror the existing `ask_questions` coverage (L109-160):
81
+
82
+ 1. `tool_execution_start` with `toolName: "question"` (args `{ question: "Approve?" }`),
83
+ interactive → `pendingQuestions = [{ toolCallId, question: "Approve?" }]`,
84
+ `semanticState === "needs_input"`, `currentTool === null`, summary = question text.
85
+ 2. Same for `toolName: "questionnaire"` with args `{ questions: [{ prompt: "Pick scope" }] }`
86
+ → extracted question "Pick scope".
87
+ 3. `tool_execution_end` with `toolName: "question"` → pendingQuestions empty,
88
+ `semanticState === "working"`.
89
+ 4. Detached (no `interactive`) `question` start → unchanged legacy behavior
90
+ (`currentTool.name === "question"`, `pendingQuestions = []`).
91
+
92
+ ## 4. Non-goals / deferred
93
+
94
+ 1. No configurable question-tool list (env/config escape hatch). Add later only if a
95
+ harness with a different question-tool name appears. Keeping the set static avoids
96
+ new config surface.
97
+ 2. No changes to `finalizeRun`, `projectViewState`, `deriveSummary`, or grouping logic.
98
+ 3. Non-interactive path behavior unchanged.
99
+ 4. No i18n / label changes.
100
+
101
+ ## 5. Edge cases
102
+
103
+ - Two concurrent question tool calls: keyed by `toolCallId`; first pending question wins
104
+ display. Unchanged from `ask_questions` behavior.
105
+ - Non-TUI pi question tool returns an error result immediately → start/end pair clears
106
+ the pending question within the same cycle; transient needs_input is harmless.
107
+ - `agent_start` / `input` events already clear `pendingQuestions` in the foreground
108
+ path (service.mjs L524-535) — no interaction.
109
+
110
+ ## 6. Verification
111
+
112
+ - `npm run verify` (project gate) with the new events tests.
113
+ - Manual: start a pi session under the board, have it call the `question` tool →
114
+ row shows the question text in NEEDS ANSWER group; answer it → row returns to RUNNING.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -17,6 +17,9 @@ import { assistantText, detectNeedsInput, toolPath, toolSummary, truncate } from
17
17
 
18
18
  const PREVIEW_MAX = 240;
19
19
 
20
+ /** Tool names whose interactive execution blocks on a user answer. */
21
+ const QUESTION_TOOL_NAMES = new Set(["ask_questions", "question", "questionnaire"]);
22
+
20
23
  /**
21
24
  * Build the initial status for a freshly-launched run.
22
25
  * @param {RunConfig} config
@@ -80,7 +83,7 @@ export function reduceEvent(status, event, now, opts = {}) {
80
83
  const name = event.toolName ?? event.args?.name ?? "tool";
81
84
  const args = event.args ?? {};
82
85
  status.toolCount += 1;
83
- if (opts.interactive && name === "ask_questions") {
86
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(name)) {
84
87
  upsertPendingQuestion(status, event.toolCallId, questionFromArgs(args));
85
88
  } else if (pendingQuestions(status).length === 0) {
86
89
  status.currentTool = { name, path: toolPath(args), summary: toolSummary(name, args) };
@@ -94,7 +97,7 @@ export function reduceEvent(status, event, now, opts = {}) {
94
97
  }
95
98
  case "tool_execution_end": {
96
99
  if (event.isError) status.error = `Tool ${event.toolName ?? ""} failed`.trim();
97
- if (opts.interactive && event.toolName === "ask_questions") removePendingQuestion(status, event.toolCallId);
100
+ if (opts.interactive && QUESTION_TOOL_NAMES.has(event.toolName ?? "")) removePendingQuestion(status, event.toolCallId);
98
101
  status.currentTool = null;
99
102
  status.semanticState = "working";
100
103
  status.question = null;
@@ -227,8 +230,9 @@ function pendingQuestions(status) {
227
230
  }
228
231
 
229
232
  function questionFromArgs(args) {
233
+ if (typeof args?.question === "string" && args.question.trim()) return args.question.trim();
230
234
  for (const item of Array.isArray(args?.questions) ? args.questions : []) {
231
- const question = String(item?.question ?? "").trim();
235
+ const question = String(item?.question ?? item?.prompt ?? "").trim();
232
236
  if (question) return question;
233
237
  }
234
238
  return "Answer the pending question";