pi-orchestrator-config 4.23.0 → 4.24.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.24.0] - 2026-11-03
4
+
5
+ - `/pr-review` asks how to decide after the findings table — `Post all`, `Skip all`, or
6
+ `Decide one by one` — instead of one bulk answer for everything.
7
+ - Deciding one by one gives each finding its own dialog (post, skip, skip with reason, more
8
+ context, stop), captures the skip reason at decision time, keeps author questions on their
9
+ Approve / Edit / Skip path, and stores a reason even for a bare skip.
10
+ - Every `ask_user` prompt offers "💬 Discuss with AI first", which returns a sentinel meaning
11
+ "talk about this decision first" rather than an answer; a caller that passes that row itself
12
+ gets one row, and `allowDiscuss: false` removes it.
13
+ - Docs: the `ask_user` reference covers `allowDiscuss` and the sentinel; the workflow page
14
+ documents the mode choice and the real turn cost.
15
+
3
16
  ## [4.23.0] - 2026-11-02
4
17
 
5
18
  - Minimum pi version raised to 1.5.0 — the extension floor, the sidecar floor, and every manifest move together.
@@ -18,6 +18,71 @@ import { createLogger } from "../shared/logger.js";
18
18
 
19
19
  const log = createLogger("ask-user");
20
20
 
21
+ /** Sentinel value returned when the user asks to discuss in plain chat first. */
22
+ export const DISCUSS_VALUE = "__discuss__";
23
+ export const DISCUSS_LABEL = "💬 Discuss with AI first";
24
+ const FREE_INPUT_VALUE = "__free_input__";
25
+
26
+ /** Emoji/punctuation-free, lowercased form so a caller's plain label still matches. */
27
+ const normalizeOption = (s: string): string =>
28
+ s.replace(/[^\p{L}\p{N}]+/gu, " ").trim().toLowerCase();
29
+
30
+ /** A caller option that already is the discuss row: its plain label or the bare sentinel. */
31
+ const isDiscussRow = (option: string): boolean =>
32
+ option.trim().toLowerCase() === DISCUSS_VALUE ||
33
+ normalizeOption(option) === normalizeOption(DISCUSS_LABEL);
34
+
35
+ export function buildAskOptions(
36
+ options: string[] | undefined,
37
+ allowDiscuss: boolean,
38
+ ): SelectItem[] {
39
+ // Free-input-only mode: no list, no discuss item.
40
+ if (!options || options.length === 0) return [];
41
+
42
+ let callerSuppliedDiscuss = false;
43
+ const items: SelectItem[] = [];
44
+ for (const opt of options) {
45
+ if (!isDiscussRow(opt)) {
46
+ items.push({ value: opt, label: opt });
47
+ continue;
48
+ }
49
+ // Opted out: a caller's discuss row must disappear, not merely go unappended.
50
+ if (!allowDiscuss) continue;
51
+ // Caller's label stays for display; the sentinel makes selecting it pause.
52
+ callerSuppliedDiscuss = true;
53
+ items.push({ value: DISCUSS_VALUE, label: opt, description: "chat first" });
54
+ }
55
+ if (!allowDiscuss) {
56
+ log.debug("ask_user discuss option suppressed", { optionCount: options.length });
57
+ } else if (callerSuppliedDiscuss) {
58
+ log.debug("ask_user discuss option deduped", { optionCount: options.length });
59
+ } else {
60
+ items.push({ value: DISCUSS_VALUE, label: DISCUSS_LABEL, description: "chat first" });
61
+ log.debug("ask_user discuss option offered", { optionCount: options.length });
62
+ }
63
+ items.push({
64
+ value: FREE_INPUT_VALUE,
65
+ label: "✎ Other (type custom answer)",
66
+ description: "free-text",
67
+ });
68
+ return items;
69
+ }
70
+
71
+ /**
72
+ * Browser/discord renders options as buttons labelled with the raw string, so the
73
+ * discuss row travels as its label and comes back mapped to the sentinel. The
74
+ * free-input row is a TUI control, not an answer — it is never offered remotely.
75
+ */
76
+ export function browserAskOptions(items: SelectItem[]): string[] {
77
+ return items
78
+ .filter((item) => item.value !== FREE_INPUT_VALUE)
79
+ .map((item) => (item.value === DISCUSS_VALUE ? DISCUSS_LABEL : item.value));
80
+ }
81
+
82
+ export function answerToValue(answer: string): string {
83
+ return answer === DISCUSS_LABEL ? DISCUSS_VALUE : answer;
84
+ }
85
+
21
86
  export function registerAskUser(
22
87
  pi: ExtensionAPI,
23
88
  terminalNotify: (title: string, body: string) => void,
@@ -32,6 +97,7 @@ export function registerAskUser(
32
97
  "Use ask_user when you need user input during a workflow (approvals, selections, confirmations).",
33
98
  "Do NOT ask users questions via plain text — always use this tool for structured choices.",
34
99
  "Provide clear, concise options. Include a 'no' or 'cancel' option when appropriate.",
100
+ `If ask_user returns exactly "${DISCUSS_VALUE}", the user has NOT answered — they want to talk about this decision first. Open by addressing the decision itself: the trade-offs, the information you are missing, or the alternatives. Never answer with a status report about what you built or verified. Re-call ask_user with the SAME question and options only once the discussion resolves.`,
35
101
  ],
36
102
  parameters: Type.Object({
37
103
  question: Type.String({
@@ -43,6 +109,12 @@ export function registerAskUser(
43
109
  "List of selectable options. If omitted, only free-text input is shown.",
44
110
  }),
45
111
  ),
112
+ allowDiscuss: Type.Optional(
113
+ Type.Boolean({
114
+ description:
115
+ "Offer a 'Discuss with AI first' option that returns the __discuss__ sentinel without answering (default true).",
116
+ }),
117
+ ),
46
118
  }),
47
119
 
48
120
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -58,14 +130,16 @@ export function registerAskUser(
58
130
 
59
131
  terminalNotify("pi", "Action required");
60
132
 
133
+ const assembled = buildAskOptions(params.options, params.allowDiscuss !== false);
134
+
61
135
  // Emit ask request for pidash browser clients
62
136
  const askId = `ask-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
63
137
  pi.events.emit("pidash:ui-request", {
64
138
  id: askId,
65
139
  type: "extension_ui_request",
66
- method: params.options?.length ? "select" : "input",
140
+ method: assembled.length ? "select" : "input",
67
141
  title: params.question,
68
- options: params.options,
142
+ options: assembled.length ? browserAskOptions(assembled) : undefined,
69
143
  });
70
144
 
71
145
  // Track if resolved (from TUI or browser)
@@ -76,20 +150,21 @@ export function registerAskUser(
76
150
  const resp = data as any;
77
151
  if (resp.id === askId && externalDone) {
78
152
  if (resp.cancelled) externalDone(null);
79
- else if (resp.value) externalDone(resp.value);
80
- else if (resp.confirmed !== undefined) externalDone(resp.confirmed ? "Yes" : "No");
153
+ else if (resp.value) {
154
+ const value = answerToValue(resp.value);
155
+ log.debug("ask_user browser answer received", { askId, isDiscuss: value === DISCUSS_VALUE });
156
+ externalDone(value);
157
+ } else if (resp.confirmed !== undefined) externalDone(resp.confirmed ? "Yes" : "No");
81
158
  }
82
159
  });
83
160
 
84
- log.debug("ask_user prompt opened with blocked status", { askId, mode: params.options?.length ? "select" : "input" });
161
+ log.debug("ask_user prompt opened with blocked status", { askId, mode: assembled.length ? "select" : "input" });
85
162
  const result = await ctx.ui.custom<string | null>(
86
163
  (tui, theme, _kb, done) => {
87
164
  // Allow browser to resolve this dialog
88
165
  externalDone = done;
89
166
 
90
- let mode: "select" | "input" = params.options?.length
91
- ? "select"
92
- : "input";
167
+ let mode: "select" | "input" = assembled.length ? "select" : "input";
93
168
  let resolved = false;
94
169
 
95
170
  const resolve = (value: string | null) => {
@@ -141,7 +216,7 @@ export function registerAskUser(
141
216
  sl.onSelect = (item: SelectItem) => {
142
217
  if (item.value === "__no_match__") {
143
218
  // Ignore — not a real option
144
- } else if (item.value === "__free_input__") {
219
+ } else if (item.value === FREE_INPUT_VALUE) {
145
220
  mode = "input";
146
221
  searchInput.setValue("");
147
222
  tui.requestRender();
@@ -153,18 +228,8 @@ export function registerAskUser(
153
228
  return sl;
154
229
  };
155
230
 
156
- if (params.options && params.options.length > 0) {
157
- allItems = [
158
- ...params.options.map((opt: string) => ({
159
- value: opt,
160
- label: opt,
161
- })),
162
- {
163
- value: "__free_input__",
164
- label: "✎ Other (type custom answer)",
165
- description: "free-text",
166
- },
167
- ];
231
+ if (assembled.length > 0) {
232
+ allItems = assembled;
168
233
  selectList = makeSelectList(allItems);
169
234
  }
170
235
 
@@ -176,7 +241,7 @@ export function registerAskUser(
176
241
  if (selected) {
177
242
  if (selected.value === "__no_match__") {
178
243
  // Ignore
179
- } else if (selected.value === "__free_input__") {
244
+ } else if (selected.value === FREE_INPUT_VALUE) {
180
245
  mode = "input";
181
246
  searchInput.setValue("");
182
247
  } else {
@@ -288,6 +353,9 @@ export function registerAskUser(
288
353
  renderResult(result, _options, theme) {
289
354
  const text = result.content[0];
290
355
  const value = text?.type === "text" ? text.text : "(no response)";
356
+ if (value === DISCUSS_VALUE) {
357
+ return new Text(theme.fg("dim", "💬 Discussion requested"), 0, 0);
358
+ }
291
359
  const icon =
292
360
  value === "User cancelled"
293
361
  ? theme.fg("warning", "✗")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.23.0",
3
+ "version": "4.24.0",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -44,6 +44,11 @@ If not found, prompt user: "myk-pi-tools is required. Install with: `uv tool ins
44
44
  - `/pr-review 123` - Review PR #123 in current repo
45
45
  - `/pr-review https://github.com/owner/repo/pull/123` - Review from URL
46
46
 
47
+ Phase 4 asks how you want to decide: `Post all`, `Skip all`, or `Decide one by one`.
48
+ Deciding one by one costs roughly one turn per finding (plus one per discussion round, and one
49
+ more when you skip with a reason or stop early) — choose it when findings need individual
50
+ judgment, not on a 20-finding wall.
51
+
47
52
  ## Task Plan
48
53
 
49
54
  Before starting any work, create ALL tasks upfront using `TaskCreate`, then set their dependencies
@@ -522,6 +527,72 @@ This avoids ambiguity — user selections for new findings always refer to the `
522
527
  - 'none' = Skip posting new findings (previous findings are still auto-posted)
523
528
  - Specific numbers = Post only those new findings (numbers refer to the `[NEW]` list)
524
529
 
530
+ **One ask decides the mode.** After printing the table, call `ask_user` once with question
531
+ `{N} findings — how do you want to decide?` and options `Post all`, `Skip all`, `Decide one by one`.
532
+ Skip this question entirely when there are ZERO `[NEW]` and ZERO `[AUTHOR-QUESTION]` items —
533
+ go straight to Phase 5 with the auto-posted prior findings (see the zero-item rule below).
534
+ Typed answers (`all`, `none`, `3,7,12`) keep working as free text. `ask_user` appends the
535
+ `💬 Discuss with AI first` row itself — never pass it as one of your own options, or the user
536
+ sees it twice.
537
+
538
+ - `Post all` → every `[NEW]` finding is selected. Continue to the refine step in bulk mode.
539
+ - `Skip all` → every `[NEW]` finding is skipped. Collect one blanket reason in bulk mode below.
540
+ - `Decide one by one` → run the per-finding loop below.
541
+ - `💬 Discuss with AI first` (`__discuss__`) → not an answer. Talk about the decision itself —
542
+ trade-offs, what is missing, the alternatives — never a status report on what you just checked —
543
+ then re-ask the same question with the same options once it resolves.
544
+
545
+ **Per-finding loop (chosen by `Decide one by one`):**
546
+
547
+ Everything after this phase — the JSON, the post step, the replies — is unchanged.
548
+
549
+ 1. Print the auto-post header block ONCE: `N prior finding(s) will be re-raised (auto-post).`
550
+ `[PREV-*]` findings are never part of the loop.
551
+ 2. For each `[AUTHOR-QUESTION]` (Q1..) and then each `[NEW]` finding (1..N), in the stable
552
+ merged order.
553
+
554
+ **`[AUTHOR-QUESTION]` items keep their own flow** — never use the finding template or the
555
+ finding actions for them:
556
+ - Print the original comment (who asked, where, full text) and the AI-generated suggested
557
+ answer from Phase 1c.
558
+ - `ask_user` with question `Question i/N — <one-line summary>` and options `Approve`, `Edit`,
559
+ `Skip`. No `Show more context` (the question body is the context) and no `Skip + reason`
560
+ (the stored reason is `Author question — user chose not to respond`).
561
+ - `Approve` → the suggested answer is posted as-is in Phase 5.
562
+ - `Edit` → print `Answer for Q{i}:` as plain chat text and STOP the turn; the user's next
563
+ message is the revised answer, which Phase 5 posts instead of the suggestion.
564
+ - `Skip` → the question is not answered; it is stored with `skip_reason`
565
+ `Author question — user chose not to respond`.
566
+
567
+ **`[NEW]` findings:**
568
+ a. Print `Finding i/N — <severity> — <file>:<line>`, the source tag, the full description,
569
+ the suggestion block, and which reviewer(s) raised it after dedup.
570
+ b. Call `ask_user` with question `Finding i/N — <one-line summary>` and exactly these options:
571
+ `Post`, `Skip`, `Skip + reason`, `Show more context`, `Stop here`. Do NOT pass
572
+ `Discuss with AI first` — `ask_user` appends that row itself.
573
+ c. Act on the answer:
574
+ - `Post` → finding is selected for posting.
575
+ - `Skip` → finding is skipped. Phase 5b still requires a reason, so store the fixed
576
+ `skip_reason` `Skipped — no reason given`; do not ask the user for one.
577
+ - `Skip + reason` → re-call `ask_user` with question `Reason for finding i/N:` and no
578
+ options, so the answer is free text; use it as the reason and continue with the next
579
+ finding. If the user asks for a multi-line reason, print `Reason for finding i/N:` as
580
+ plain chat text and STOP the turn instead — their next message is the reason.
581
+ - `Show more context` → print the surrounding diff hunk and the affected call sites, then
582
+ re-ask the SAME finding (same index, same options).
583
+ - `Discuss with AI first` → `ask_user` returns exactly `__discuss__`. That is NOT an answer:
584
+ the user wants to talk about this finding. Discuss the finding itself — its trade-offs,
585
+ what you would need to know to judge it, the alternatives — and do not report on your own
586
+ workflow. Re-ask the SAME finding once the discussion resolves. `__discuss__` is a control
587
+ value — never post it, never store it, never write it to `.pi/data/review-guidelines.md`.
588
+ - `Stop here` → every remaining finding is skipped. Ask once for a blanket reason: use
589
+ `ask_user` with no options when one line is enough, otherwise print the prompt as plain
590
+ chat text and STOP the turn. If the user gives no reason, store the fixed
591
+ `skip_reason` `Skipped — no reason given` (Phase 5b rejects an empty one).
592
+ 3. The reasons collected here feed the SAME refine → classify → `review-guidelines.md` steps
593
+ below. Only the timing differs: the reason is captured while the user is looking at that
594
+ finding instead of in a later bulk round.
595
+
525
596
  If there are ZERO `[NEW]` findings AND zero `[AUTHOR-QUESTION]` findings, skip user
526
597
  selection entirely — auto-post the previous findings and proceed directly to Phase 5
527
598
  (the auto-post path still generates the comments JSON and posts them — see Phase 5).
@@ -532,10 +603,16 @@ user approval/edit/skip.
532
603
 
533
604
  🚨 **Skip reason collection (MANDATORY — NEVER SKIP THIS STEP):**
534
605
 
535
- **HARD RULE: If the user skipped ANY finding (said 'no', selected specific numbers
536
- that exclude some findings, or said 'none'), you MUST ask for skip reasons
537
- BEFORE proceeding to Phase 5. Do NOT continue the workflow without collecting
538
- skip reasons. Skipping this step is a HARD VIOLATION.**
606
+ In the per-finding loop the reasons were already captured as you went — go straight to
607
+ step 2 (AI refines each reason) and do not prompt again.
608
+
609
+ **Bulk mode only (Post all / Skip all / numbers) — HARD RULE: If the user skipped ANY finding
610
+ (said 'no' or 'Skip all', selected specific numbers that exclude some findings), you MUST ask
611
+ for skip reasons BEFORE proceeding to Phase 5.** Do NOT continue the workflow without collecting
612
+ skip reasons. Skipping this step is a HARD VIOLATION.
613
+
614
+ If the user declines to give any reason, still store one — Phase 5b rejects an empty
615
+ `skip_reason` — using the fixed text `Skipped — no reason given`.
539
616
 
540
617
  After the user selects which findings to post, compute the skipped set (total `[NEW]`
541
618
  findings minus user-selected findings). If the skipped set is non-empty: