pi-do-always 0.15.0 → 0.16.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/README.md +67 -0
- package/extensions/pi-do-always/index.ts +576 -12
- package/extensions/pi-do-always/tasks.ts +409 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
|
|
|
20
20
|
|`/do-always review changes`|Fill the prompt for the task named `review changes` (task names autocomplete after `/do-always`)|
|
|
21
21
|
|`/do-always list`|Print the task list|
|
|
22
22
|
|`/do-always list-details`|Show the full rendered prompt text each task will inject|
|
|
23
|
+
|`/do-always replan`|Re-open the plan questionnaire for the last offered proposal (e.g. after an accidental Esc)|
|
|
23
24
|
|
|
24
25
|
The selector supports direct number-pick (1-9), live type-to-filter, arrow/Enter navigation,
|
|
25
26
|
mouse-wheel scrolling, and click-to-select. While a filter is active, typed digits refine the
|
|
@@ -136,6 +137,68 @@ substituted in; any other prompt gets the block appended under
|
|
|
136
137
|
the latest commit is selected and the first eligible `Plan` task (not
|
|
137
138
|
`notForCommits`) whose guards pass runs on it.
|
|
138
139
|
|
|
140
|
+
## Plan questionnaire
|
|
141
|
+
|
|
142
|
+
Auto-run `Plan` tasks (⚡) are asked to end their reply with a
|
|
143
|
+
machine-readable `plan` block: a one-line summary plus the proposed action
|
|
144
|
+
items grouped into priority tiers (`P0`, `P1`, …). When the run settles, the
|
|
145
|
+
reply is parsed and — if a plan block is present — a **questionnaire** opens
|
|
146
|
+
in the TUI. The block is the data channel, but by default it is hidden: as
|
|
147
|
+
soon as the reply is finalized, the extension strips the block from the
|
|
148
|
+
transcript in the TUI (the prose around it is the human-facing summary), so
|
|
149
|
+
the conversation stays clean — the questionnaire still works, because the
|
|
150
|
+
raw text is captured before the strip. In non-TUI modes the block stays in
|
|
151
|
+
the transcript, so the model can resolve the item-number replies the
|
|
152
|
+
notification offers. Set `hidePlan` to `false` (per task or
|
|
153
|
+
globally) to keep the block visible. With the questionnaire disabled the
|
|
154
|
+
block is not requested at all:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
Plan proposal — Review changes
|
|
158
|
+
2 critical bugs, 3 cleanups
|
|
159
|
+
|
|
160
|
+
[·] P0 — Critical (0/2)
|
|
161
|
+
· Fix null deref in parse()
|
|
162
|
+
· Validate input length
|
|
163
|
+
[◐] P1 — Important (1/3)
|
|
164
|
+
✓ Remove unused imports
|
|
165
|
+
· Drop dead config flag
|
|
166
|
+
· Tighten error message
|
|
167
|
+
|
|
168
|
+
─────────────────────────────
|
|
169
|
+
Confirm (1/5)
|
|
170
|
+
space/⏎ toggle • a all • ctrl+u clear • e note • ⏎ confirm • esc withdraw
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- **↑/↓** (wrapping), **Home/End** move the cursor; **Space** or **Enter**
|
|
174
|
+
toggles the row under the cursor — a tier row toggles the whole tier
|
|
175
|
+
(`·` none → `◐` partial → `✓` all), an item row toggles just that item.
|
|
176
|
+
- **a** (or **Ctrl+A**) selects everything; **Ctrl+U** clears the selection.
|
|
177
|
+
- **e** on an item row opens a note editor for that item (Enter saves, Esc
|
|
178
|
+
cancels). The note is shown on the row (`✎ …`) and appended to that item
|
|
179
|
+
in the execution prompt — a way to steer an item without retyping it.
|
|
180
|
+
- Long plans scroll: the list shows 12 rows at a time and the window follows
|
|
181
|
+
the cursor (**↑/↓**, **Home/End**, or the mouse wheel); a `(n/N)` marker
|
|
182
|
+
shows the position. The `Confirm` row stays pinned.
|
|
183
|
+
- **Enter** (or a mouse click) on the pinned `Confirm (n/N)` row sends the
|
|
184
|
+
selection as a single follow-up turn: the agent executes exactly the
|
|
185
|
+
selected items, in tier order, and nothing else. **Esc** withdraws —
|
|
186
|
+
nothing is sent and the proposal is kept in memory; re-open it with
|
|
187
|
+
`/do-always replan` (an accidental Esc is cheap to undo).
|
|
188
|
+
- Mouse: clicking a tier/item row toggles it; clicking the Confirm row
|
|
189
|
+
confirms.
|
|
190
|
+
|
|
191
|
+
Nothing is preselected. A Plan run that finds nothing to do replies with an
|
|
192
|
+
empty tier list, and you just get the usual one-line summary. If the reply
|
|
193
|
+
has no parseable `plan` block, the questionnaire is disabled, or the mode is
|
|
194
|
+
not the TUI, the extension falls back to the plain summary notification —
|
|
195
|
+
and when a questionnaire was expected, the notification says why (no plan
|
|
196
|
+
block in the reply, or the block is not valid JSON), so a fallback is never
|
|
197
|
+
a silent mystery. In non-TUI modes a parseable proposal is listed as a
|
|
198
|
+
notification instead, so you can reply with the item numbers to execute. The
|
|
199
|
+
questionnaire is offered only on the single auto-run path (selector pick,
|
|
200
|
+
`/do-always <n>`, commit picker) — chain steps never get it.
|
|
201
|
+
|
|
139
202
|
## Install
|
|
140
203
|
|
|
141
204
|
Install it from npm as a Pi package, which loads the bundled `index.ts` (and its `tasks.ts`) without
|
|
@@ -190,6 +253,8 @@ Fields:
|
|
|
190
253
|
- `browser` (optional) — a browser to open on selection instead of injecting the prompt. Only `"commits"` is supported: it opens the date-grouped commit browser, and after the selection the task runs on the selected commits — directly when its prompt references `{{selected_commits}}`, otherwise via a picker of `Plan` tasks (see [Commit browser](#commit-browser)). An invalid value is ignored with a warning.
|
|
191
254
|
- `hidden` (optional) — when `true`, the task is not shown in the selector or in `/do-always list` / `list-details`. Unlike a `when` condition, a hidden task can still be run by name (`/do-always <name>`), and it is offered as a candidate by the commit picker. The built-in `Review commits` uses this: it is a pick-after-browse option, not a standalone entry.
|
|
192
255
|
- `notForCommits` (optional) — when `true`, the task is excluded from the commit picker (the “run on the selected commits” list) because it does not operate on a set of commits. The task is otherwise unaffected (selector, lists, CLI). The built-in `Review changes`, `Review code`, and `Propose features` use this.
|
|
256
|
+
- `questionnaire` (optional) — whether the plan questionnaire is offered after this task's completed run (see [Plan questionnaire](#plan-questionnaire)). Default `true`; set `false` to keep the plain summary notification.
|
|
257
|
+
- `hidePlan` (optional) — whether the raw `plan` block is hidden from the transcript after this task's completed run: the fenced block is stripped from the finalized reply (TUI only — in non-TUI modes the block is always kept so the model can resolve item-number replies). Default `true`; set `false` to keep the block visible in the conversation.
|
|
193
258
|
- `when` (optional) — a condition that hides the task from the selector and lists when it is not met (see [Conditionals](#conditionals)).
|
|
194
259
|
- `guards` (optional) — an array of selection-time guards that block the task (with a message, not a hide) when a condition is unmet (see [Guards](#guards)). The legacy `requireDirty` (boolean) still works and is combined with any `guards`.
|
|
195
260
|
|
|
@@ -198,6 +263,8 @@ In the object form you can also configure the selector shortcut:
|
|
|
198
263
|
- `shortcut` (optional) — key that opens the selector, e.g. `"f4"`. Set to `null` to disable the shortcut. Defaults to `F4`. The project file's value wins over the global one.
|
|
199
264
|
- `merge` (optional) — how project tasks combine with the global tasks: `"override"` (default) replaces a global task with the same `name`; `"append"` keeps the global tasks and only adds new project task names (a cascade, like CSS). The project file's value wins over the global one; when neither sets it, the default is `override` (the historical behavior).
|
|
200
265
|
- `report` (optional) — whether chain runs write a Markdown report file in the project root (one per run, appended as each step finishes). Default `true`; set `false` to disable. The project file's value wins over the global one. See [Chains](#chains).
|
|
266
|
+
- `questionnaire` (optional) — whether completed auto-run tasks whose reply carries a plan block offer the selection questionnaire. Default `true`; set `false` to keep the plain summary notification. The project file's value wins over the global one. See [Plan questionnaire](#plan-questionnaire).
|
|
267
|
+
- `hidePlan` (optional) — whether the raw `plan` block is stripped from the transcript after a completed auto-run task (TUI only — in non-TUI modes the block is always kept). Default `true`; set `false` to keep the block visible in the conversation. The project file's value wins over the global one.
|
|
201
268
|
|
|
202
269
|
Example project file that only *adds* tasks without overriding the global set:
|
|
203
270
|
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
import { execFile } from "node:child_process";
|
|
37
37
|
import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
38
38
|
import { join, relative } from "node:path";
|
|
39
|
-
import type { AgentEndEvent, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import type { AgentEndEvent, ExtensionAPI, ExtensionContext, MessageEndEvent, Theme } from "@earendil-works/pi-coding-agent";
|
|
40
40
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
41
41
|
import {
|
|
42
42
|
type KeyId,
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
COMMIT_SELECT_MAX,
|
|
56
56
|
DEFAULT_SHORTCUT,
|
|
57
57
|
DEFAULT_TASKS,
|
|
58
|
+
PLAN_OUTPUT_INSTRUCTION,
|
|
58
59
|
assistantText,
|
|
59
60
|
buildTableRows,
|
|
60
61
|
chainAdd,
|
|
@@ -67,9 +68,11 @@ import {
|
|
|
67
68
|
evaluateWhen,
|
|
68
69
|
formatChainSequence,
|
|
69
70
|
formatList,
|
|
71
|
+
formatPlanExecutionPrompt,
|
|
70
72
|
formatSelectedCommits,
|
|
71
73
|
groupCommitsByDate,
|
|
72
74
|
groupTasksByCategory,
|
|
75
|
+
isPlanTask,
|
|
73
76
|
isTaskVisible,
|
|
74
77
|
isValidKeyId,
|
|
75
78
|
mergeTasks,
|
|
@@ -77,9 +80,18 @@ import {
|
|
|
77
80
|
parseConfigRegexpValueForKey,
|
|
78
81
|
parseCommitSubject,
|
|
79
82
|
parseGitLogOutput,
|
|
83
|
+
parsePlanProposal,
|
|
80
84
|
parseStatusPorcelain,
|
|
81
85
|
parseStatusStagedUnstaged,
|
|
82
86
|
orderTasksByCategory,
|
|
87
|
+
planBlockDiagnostics,
|
|
88
|
+
planItemKey,
|
|
89
|
+
planSelectAll,
|
|
90
|
+
planSelectionClear,
|
|
91
|
+
planSelectedItems,
|
|
92
|
+
planTierState,
|
|
93
|
+
planToggleItem,
|
|
94
|
+
planToggleTier,
|
|
83
95
|
reportAbandonedFooter,
|
|
84
96
|
reportFooter,
|
|
85
97
|
reportHeader,
|
|
@@ -91,12 +103,16 @@ import {
|
|
|
91
103
|
resolveTask,
|
|
92
104
|
shouldAutoRun,
|
|
93
105
|
stepSummary,
|
|
106
|
+
stripPlanBlocks,
|
|
94
107
|
toPromptContext,
|
|
95
108
|
validateChain,
|
|
96
109
|
type ChainStepOutcome,
|
|
97
110
|
type CommitInfo,
|
|
98
111
|
type DateGroup,
|
|
99
112
|
type DoAlwaysTask,
|
|
113
|
+
type PlanProposal,
|
|
114
|
+
type PlanSelection,
|
|
115
|
+
type PlanSelectionEntry,
|
|
100
116
|
type SelectedCommit,
|
|
101
117
|
type TaskContext,
|
|
102
118
|
type TableRow,
|
|
@@ -149,6 +165,10 @@ function loadConfig(
|
|
|
149
165
|
shortcut: string | null;
|
|
150
166
|
/** Whether chain runs write a Markdown report file (default true). */
|
|
151
167
|
report: boolean;
|
|
168
|
+
/** Whether plan questionnaires are offered (default true). */
|
|
169
|
+
questionnaire: boolean;
|
|
170
|
+
/** Whether the raw plan block is hidden from the transcript (default true). */
|
|
171
|
+
hidePlan: boolean;
|
|
152
172
|
} {
|
|
153
173
|
const globalPath = join(getAgentDir(), "do-always.json");
|
|
154
174
|
const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
|
|
@@ -158,10 +178,10 @@ function loadConfig(
|
|
|
158
178
|
|
|
159
179
|
const global = globalRaw !== null
|
|
160
180
|
? parseConfig(globalRaw, globalPath, onError)
|
|
161
|
-
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
181
|
+
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
162
182
|
const project = projectRaw !== null
|
|
163
183
|
? parseConfig(projectRaw, projectPath, onError)
|
|
164
|
-
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
184
|
+
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
165
185
|
|
|
166
186
|
// The project file's merge mode wins; otherwise the global value; otherwise
|
|
167
187
|
// override (the historical behavior), so existing configs are unaffected.
|
|
@@ -175,6 +195,10 @@ function loadConfig(
|
|
|
175
195
|
// The project file's value wins; otherwise the global value; otherwise
|
|
176
196
|
// reports are on.
|
|
177
197
|
report: project.report ?? global.report ?? true,
|
|
198
|
+
// Same precedence: project, then global, then on.
|
|
199
|
+
questionnaire: project.questionnaire ?? global.questionnaire ?? true,
|
|
200
|
+
// Same precedence: project, then global, then on (the block is hidden).
|
|
201
|
+
hidePlan: project.hidePlan ?? global.hidePlan ?? true,
|
|
178
202
|
};
|
|
179
203
|
}
|
|
180
204
|
|
|
@@ -950,10 +974,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
950
974
|
// flag would post a spurious summary for the next unrelated turn.
|
|
951
975
|
let pendingSummaryTask: string | null = null;
|
|
952
976
|
let pendingSummaryTimer: NodeJS.Timeout | null = null;
|
|
977
|
+
// The completed auto-run task's reply, captured at agent_end and offered
|
|
978
|
+
// at agent_settled (the session is fully idle then, so the confirm
|
|
979
|
+
// follow-up cannot race queued continuations). Discarded on agent_start
|
|
980
|
+
// — a new run started, so the proposal is stale — and on session start.
|
|
981
|
+
let pendingProposal: { taskName: string; text: string } | null = null;
|
|
982
|
+
// The last offered plan proposal (parseable, questionnaire enabled), kept
|
|
983
|
+
// so `/do-always replan` can re-open the questionnaire after a
|
|
984
|
+
// withdrawal. Cleared on confirm (executed) and on session start.
|
|
985
|
+
let lastProposal: { taskName: string; text: string } | null = null;
|
|
986
|
+
// The raw (unstripped) reply text of the pending auto-run task, captured
|
|
987
|
+
// at message_end before its plan block is stripped from the transcript
|
|
988
|
+
// (by the time agent_end fires, the message there is already stripped).
|
|
989
|
+
// Cleared on agent_start (a new run makes it stale), agent_end (consumed
|
|
990
|
+
// or discarded), and session start.
|
|
991
|
+
let pendingPlanRaw: string | null = null;
|
|
953
992
|
|
|
954
993
|
/** Clear the auto-run summary flag and its grace timer (session start). */
|
|
955
994
|
function resetPendingSummary(): void {
|
|
956
995
|
pendingSummaryTask = null;
|
|
996
|
+
// A completed auto-run's captured reply is only offered at the
|
|
997
|
+
// settle that follows its own agent_end — a new run invalidates it.
|
|
998
|
+
pendingProposal = null;
|
|
999
|
+
lastProposal = null;
|
|
1000
|
+
pendingPlanRaw = null;
|
|
957
1001
|
if (pendingSummaryTimer) {
|
|
958
1002
|
clearTimeout(pendingSummaryTimer);
|
|
959
1003
|
pendingSummaryTimer = null;
|
|
@@ -962,6 +1006,410 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
962
1006
|
// Whether chain runs write a Markdown report file (config `report`,
|
|
963
1007
|
// default true). Refreshed whenever the config is (re)loaded.
|
|
964
1008
|
let reportEnabled = true;
|
|
1009
|
+
// Whether completed auto-run tasks whose reply carries a "plan" block
|
|
1010
|
+
// offer the selection questionnaire (config `questionnaire`, default
|
|
1011
|
+
// true). Refreshed whenever the config is (re)loaded.
|
|
1012
|
+
let questionnaireEnabled = true;
|
|
1013
|
+
// Whether the raw "plan" block is stripped from the transcript after a
|
|
1014
|
+
// completed auto-run task (config `hidePlan`, default true). Refreshed
|
|
1015
|
+
// whenever the config is (re)loaded.
|
|
1016
|
+
let hidePlanEnabled = true;
|
|
1017
|
+
|
|
1018
|
+
/** The result of the plan questionnaire: the confirmed selection, or a withdrawal. */
|
|
1019
|
+
type PlanQuestionnaireResult =
|
|
1020
|
+
| { kind: "confirm"; items: PlanSelectionEntry[] }
|
|
1021
|
+
| { kind: "withdraw" };
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Render a task's prompt for injection or preview. Plan-category tasks get
|
|
1025
|
+
* PLAN_OUTPUT_INSTRUCTION appended (once) so the reply carries the
|
|
1026
|
+
* machine-readable "plan" block the questionnaire parses — but only when
|
|
1027
|
+
* the questionnaire is actually offered for this task, otherwise the agent
|
|
1028
|
+
* would emit a block nobody reads. A prompt that already mentions the plan
|
|
1029
|
+
* fence keeps its own contract; chain-step prompts are rendered with the
|
|
1030
|
+
* plain renderPrompt and never get it.
|
|
1031
|
+
*/
|
|
1032
|
+
function renderTaskPrompt(task: DoAlwaysTask, ctx: Record<string, string>): string {
|
|
1033
|
+
const base = renderPrompt(task.prompt, ctx);
|
|
1034
|
+
const enabled = task.questionnaire ?? questionnaireEnabled;
|
|
1035
|
+
if (isPlanTask(task) && enabled && !base.includes("```plan")) {
|
|
1036
|
+
return `${base}\n\n${PLAN_OUTPUT_INSTRUCTION}`;
|
|
1037
|
+
}
|
|
1038
|
+
return base;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Offer a completed auto-run task's reply. When it carries a parseable
|
|
1043
|
+
* "plan" block and the questionnaire is enabled, the TUI shows the tier/
|
|
1044
|
+
* item questionnaire: confirming sends the selection as an execution
|
|
1045
|
+
* follow-up, Esc withdraws (nothing happens). Everything else — disabled,
|
|
1046
|
+
* non-TUI, or no plan block — falls back to the plain summary
|
|
1047
|
+
* notification.
|
|
1048
|
+
*/
|
|
1049
|
+
async function offerPlanProposal(
|
|
1050
|
+
captured: { taskName: string; text: string },
|
|
1051
|
+
ctx: ExtensionContext | null,
|
|
1052
|
+
): Promise<void> {
|
|
1053
|
+
const proposal = parsePlanProposal(captured.text);
|
|
1054
|
+
if (!ctx) return;
|
|
1055
|
+
const task = tasks.find((t) => t.name === captured.taskName);
|
|
1056
|
+
const enabled = task?.questionnaire ?? questionnaireEnabled;
|
|
1057
|
+
// The prompt asked for a plan block only for Plan tasks with the
|
|
1058
|
+
// questionnaire enabled (renderTaskPrompt's gate) — only then is a
|
|
1059
|
+
// missing or invalid block a contract violation worth explaining; for
|
|
1060
|
+
// a non-Plan auto-run task its absence is the expected outcome.
|
|
1061
|
+
const blockExpected = task !== undefined && isPlanTask(task) && enabled;
|
|
1062
|
+
if (!proposal || !enabled) {
|
|
1063
|
+
const base = `do-always: ${stepSummary("completed", captured.taskName, 0, 0)}`;
|
|
1064
|
+
if (!blockExpected) {
|
|
1065
|
+
// Questionnaire disabled, or a non-Plan auto-run task: the
|
|
1066
|
+
// prompt never asked for a plan block, so its absence is not a
|
|
1067
|
+
// contract violation — plain summary.
|
|
1068
|
+
ctx.ui.notify(base, "info");
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
// The prompt asked for a plan block but none was usable — say why,
|
|
1072
|
+
// so the fallback to a plain summary is not a mystery.
|
|
1073
|
+
const diag = planBlockDiagnostics(captured.text);
|
|
1074
|
+
if (diag.kind === "none") {
|
|
1075
|
+
ctx.ui.notify(`${base} — reply had no plan block, so no questionnaire was offered`, "warning");
|
|
1076
|
+
} else if (diag.kind === "malformed") {
|
|
1077
|
+
ctx.ui.notify(`${base} — plan block was not valid JSON (${diag.detail}); no questionnaire offered`, "warning");
|
|
1078
|
+
} else {
|
|
1079
|
+
// "empty": the agent proposed no action items — a legitimate
|
|
1080
|
+
// outcome, not a contract violation.
|
|
1081
|
+
ctx.ui.notify(`${base} — no action items proposed`, "info");
|
|
1082
|
+
}
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
// Remember the last offered proposal so /do-always replan can re-open
|
|
1086
|
+
// it after a withdrawal.
|
|
1087
|
+
lastProposal = captured;
|
|
1088
|
+
if (ctx.mode !== "tui") {
|
|
1089
|
+
// Non-TUI: list the proposed items so the user can reply with a
|
|
1090
|
+
// selection; nothing is sent automatically.
|
|
1091
|
+
const all = planSelectedItems(proposal, planSelectAll(proposal, planSelectionClear()));
|
|
1092
|
+
const list = all.map(({ tier, item }, i) => ` ${i + 1}. [${tier.id}] ${item.title}`).join("\n");
|
|
1093
|
+
ctx.ui.notify(
|
|
1094
|
+
`do-always: "${captured.taskName}" proposed ${all.length} action item(s):\n${list}\nReply with the item numbers to execute (or do nothing to withdraw).`,
|
|
1095
|
+
"info",
|
|
1096
|
+
);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const result = await showPlanQuestionnaire(ctx, proposal, captured.taskName);
|
|
1100
|
+
if (result.kind === "confirm") {
|
|
1101
|
+
lastProposal = null; // executed — nothing left to re-offer
|
|
1102
|
+
const prompt = formatPlanExecutionPrompt(result.items, captured.taskName);
|
|
1103
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
1104
|
+
ctx.ui.notify(
|
|
1105
|
+
`do-always: executing ${result.items.length} selected item(s) from the "${captured.taskName}" plan`,
|
|
1106
|
+
"info",
|
|
1107
|
+
);
|
|
1108
|
+
} else {
|
|
1109
|
+
ctx.ui.notify(`do-always: plan withdrawn — no action taken (re-open with /do-always replan)`, "info");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Plan questionnaire: shown after a completed auto-run task whose reply
|
|
1115
|
+
* carries a parseable "plan" block. The summary line up top, then the tiers
|
|
1116
|
+
* with their action items. Selecting a tier row toggles the whole tier (all
|
|
1117
|
+
* its items); selecting an item row toggles just that item. The pinned
|
|
1118
|
+
* Confirm row sends the selection as an execution follow-up; Esc withdraws
|
|
1119
|
+
* (nothing happens).
|
|
1120
|
+
*/
|
|
1121
|
+
function showPlanQuestionnaire(
|
|
1122
|
+
ctx: ExtensionContext,
|
|
1123
|
+
proposal: PlanProposal,
|
|
1124
|
+
taskName: string,
|
|
1125
|
+
): Promise<PlanQuestionnaireResult> {
|
|
1126
|
+
return new Promise<PlanQuestionnaireResult>((resolve) => {
|
|
1127
|
+
ctx.ui.custom<PlanQuestionnaireResult>((tui, theme, _kb, done) => {
|
|
1128
|
+
const kb = getKeybindings();
|
|
1129
|
+
let settled = false;
|
|
1130
|
+
let selection: PlanSelection = planSelectionClear();
|
|
1131
|
+
|
|
1132
|
+
// Flat cursor rows: a tier header row, its item rows, and the
|
|
1133
|
+
// pinned Confirm row last.
|
|
1134
|
+
type Row =
|
|
1135
|
+
| { kind: "tier"; index: number }
|
|
1136
|
+
| { kind: "item"; tier: number; index: number }
|
|
1137
|
+
| { kind: "confirm" };
|
|
1138
|
+
const rows: Row[] = [];
|
|
1139
|
+
proposal.tiers.forEach((tier, ti) => {
|
|
1140
|
+
rows.push({ kind: "tier", index: ti });
|
|
1141
|
+
tier.items.forEach((_, ii) => rows.push({ kind: "item", tier: ti, index: ii }));
|
|
1142
|
+
});
|
|
1143
|
+
rows.push({ kind: "confirm" });
|
|
1144
|
+
// The scrollable part: everything but the pinned Confirm row.
|
|
1145
|
+
// (Cast — slice() does not narrow the row union; the last row
|
|
1146
|
+
// is always the confirm row pushed above.)
|
|
1147
|
+
type ListRow = Exclude<Row, { kind: "confirm" }>;
|
|
1148
|
+
const listRows = rows.slice(0, -1) as ListRow[];
|
|
1149
|
+
let cursor = 0;
|
|
1150
|
+
const MAX_VISIBLE = 12; // tier/item rows visible in the scroll window
|
|
1151
|
+
// Per-item notes (item key → note), added with `e` on an item
|
|
1152
|
+
// row and carried into the execution prompt on confirm.
|
|
1153
|
+
const notes = new Map<string, string>();
|
|
1154
|
+
let noteKey: string | null = null; // the item being annotated
|
|
1155
|
+
let noteDraft = "";
|
|
1156
|
+
|
|
1157
|
+
function finish(result: PlanQuestionnaireResult) {
|
|
1158
|
+
if (settled) return;
|
|
1159
|
+
settled = true;
|
|
1160
|
+
done(result);
|
|
1161
|
+
resolve(result);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function tierCount(ti: number): string {
|
|
1165
|
+
const tier = proposal.tiers[ti];
|
|
1166
|
+
let n = 0;
|
|
1167
|
+
tier.items.forEach((_, ii) => {
|
|
1168
|
+
if (selection.has(planItemKey(ti, ii))) n++;
|
|
1169
|
+
});
|
|
1170
|
+
return `${n}/${tier.items.length}`;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Line map for mouse hit-testing (rebuilt on every render).
|
|
1174
|
+
let rowLine = new Map<number, Row>();
|
|
1175
|
+
|
|
1176
|
+
function render(width: number): string[] {
|
|
1177
|
+
rowLine = new Map();
|
|
1178
|
+
const lines: string[] = [];
|
|
1179
|
+
lines.push(
|
|
1180
|
+
theme.fg("accent", theme.bold(truncateToWidth(` Plan proposal — ${taskName}`, width - 2, ""))),
|
|
1181
|
+
);
|
|
1182
|
+
if (proposal.summary) {
|
|
1183
|
+
lines.push(theme.fg("muted", truncateToWidth(` ${proposal.summary}`, width - 2, "…")));
|
|
1184
|
+
}
|
|
1185
|
+
lines.push("");
|
|
1186
|
+
// Scroll window over the tier/item rows (the Confirm row is
|
|
1187
|
+
// pinned below it). The window follows the cursor, clamped at
|
|
1188
|
+
// both edges — the same pattern as the task selector.
|
|
1189
|
+
const anchor = cursor < listRows.length ? cursor : listRows.length - 1;
|
|
1190
|
+
const winStart =
|
|
1191
|
+
listRows.length > MAX_VISIBLE
|
|
1192
|
+
? Math.max(0, Math.min(anchor + 1 - MAX_VISIBLE, listRows.length - MAX_VISIBLE))
|
|
1193
|
+
: 0;
|
|
1194
|
+
const winEnd = Math.min(winStart + MAX_VISIBLE, listRows.length);
|
|
1195
|
+
for (let i = winStart; i < winEnd; i++) {
|
|
1196
|
+
const row = listRows[i];
|
|
1197
|
+
if (row.kind === "tier") {
|
|
1198
|
+
const ti = row.index;
|
|
1199
|
+
const tier = proposal.tiers[ti];
|
|
1200
|
+
const state = planTierState(proposal, ti, selection);
|
|
1201
|
+
const glyph = state === "all" ? "✓" : state === "partial" ? "◐" : "·";
|
|
1202
|
+
const color = state === "all" ? "success" : state === "partial" ? "warning" : "dim";
|
|
1203
|
+
const headerText = ` [${glyph}] ${tier.id} — ${tier.label} (${tierCount(ti)})`;
|
|
1204
|
+
if (cursor === i) {
|
|
1205
|
+
lines.push(theme.bg("selectedBg", theme.bold(headerText)));
|
|
1206
|
+
} else {
|
|
1207
|
+
lines.push(` ${theme.fg(color, `[${glyph}]`)} ${tier.id} — ${tier.label} (${tierCount(ti)})`);
|
|
1208
|
+
}
|
|
1209
|
+
rowLine.set(lines.length - 1, row);
|
|
1210
|
+
} else {
|
|
1211
|
+
const ti = row.tier;
|
|
1212
|
+
const ii = row.index;
|
|
1213
|
+
const key = planItemKey(ti, ii);
|
|
1214
|
+
const item = proposal.tiers[ti].items[ii];
|
|
1215
|
+
const mark = selection.has(key) ? theme.fg("success", "✓") : theme.fg("dim", "·");
|
|
1216
|
+
const note = notes.get(key);
|
|
1217
|
+
const title = truncateToWidth(
|
|
1218
|
+
note ? `${item.title} ✎ ${note}` : item.title,
|
|
1219
|
+
Math.max(10, width - 8),
|
|
1220
|
+
"…",
|
|
1221
|
+
);
|
|
1222
|
+
const rowText = ` ${mark} ${title}`;
|
|
1223
|
+
if (cursor === i) {
|
|
1224
|
+
lines.push(theme.bg("selectedBg", theme.bold(rowText)));
|
|
1225
|
+
} else {
|
|
1226
|
+
lines.push(rowText);
|
|
1227
|
+
}
|
|
1228
|
+
rowLine.set(lines.length - 1, row);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// Scroll position marker (only when the list overflows the window).
|
|
1232
|
+
if (listRows.length > MAX_VISIBLE) {
|
|
1233
|
+
lines.push(theme.fg("dim", ` (${anchor + 1}/${listRows.length})`));
|
|
1234
|
+
}
|
|
1235
|
+
// Pinned Confirm row (always visible, outside the scroll window).
|
|
1236
|
+
lines.push("");
|
|
1237
|
+
lines.push(theme.fg("dim", ` ${"─".repeat(Math.max(1, width - 4))}`));
|
|
1238
|
+
const totalItems = proposal.tiers.reduce((n, t) => n + t.items.length, 0);
|
|
1239
|
+
const confirmLabel = `Confirm (${selection.size}/${totalItems})`;
|
|
1240
|
+
if (cursor === rows.length - 1) {
|
|
1241
|
+
lines.push(theme.bg("selectedBg", theme.bold(`${theme.fg("accent", "►")} ${confirmLabel}`)));
|
|
1242
|
+
} else if (selection.size > 0) {
|
|
1243
|
+
lines.push(theme.fg("accent", ` ${confirmLabel}`));
|
|
1244
|
+
} else {
|
|
1245
|
+
lines.push(theme.fg("dim", ` ${confirmLabel}`));
|
|
1246
|
+
}
|
|
1247
|
+
rowLine.set(lines.length - 1, { kind: "confirm" });
|
|
1248
|
+
if (noteKey !== null) {
|
|
1249
|
+
// Note editor: replaces the key hint while active.
|
|
1250
|
+
lines.push(theme.fg("accent", truncateToWidth(` note> ${noteDraft}`, width - 2, "")));
|
|
1251
|
+
lines.push(theme.fg("dim", " enter save note • esc cancel"));
|
|
1252
|
+
} else {
|
|
1253
|
+
lines.push(
|
|
1254
|
+
theme.fg(
|
|
1255
|
+
"dim",
|
|
1256
|
+
truncateToWidth(
|
|
1257
|
+
` space/⏎ toggle • a all • ctrl+u clear • e note • ⏎ confirm • esc withdraw`,
|
|
1258
|
+
width - 2,
|
|
1259
|
+
"",
|
|
1260
|
+
),
|
|
1261
|
+
),
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
return lines;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function toggleAt(row: Row) {
|
|
1268
|
+
if (row.kind === "tier") {
|
|
1269
|
+
selection = planToggleTier(proposal, row.index, selection).selection;
|
|
1270
|
+
} else if (row.kind === "item") {
|
|
1271
|
+
selection = planToggleItem(selection, planItemKey(row.tier, row.index));
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function confirmIfPossible(): boolean {
|
|
1276
|
+
const items = planSelectedItems(proposal, selection, notes);
|
|
1277
|
+
if (items.length === 0) {
|
|
1278
|
+
ctx.ui.notify("do-always: nothing selected — pick a tier or item first (or esc to withdraw)", "info");
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
finish({ kind: "confirm", items });
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function handleInput(data: string) {
|
|
1286
|
+
if (settled) return;
|
|
1287
|
+
// Note mode: capture the note for the item under the cursor.
|
|
1288
|
+
// Enter saves (an empty note clears it), Esc cancels (the
|
|
1289
|
+
// previous note, if any, is kept).
|
|
1290
|
+
if (noteKey !== null) {
|
|
1291
|
+
if (matchesKey(data, "enter") || kb.matches(data, "tui.select.confirm")) {
|
|
1292
|
+
const trimmed = noteDraft.trim();
|
|
1293
|
+
if (trimmed) notes.set(noteKey, trimmed);
|
|
1294
|
+
else notes.delete(noteKey);
|
|
1295
|
+
noteKey = null;
|
|
1296
|
+
noteDraft = "";
|
|
1297
|
+
tui.requestRender();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
1301
|
+
noteKey = null;
|
|
1302
|
+
noteDraft = "";
|
|
1303
|
+
tui.requestRender();
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
|
1307
|
+
noteDraft = noteDraft.slice(0, -1);
|
|
1308
|
+
tui.requestRender();
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (isPrintable(data)) {
|
|
1312
|
+
if (noteDraft.length < 200) noteDraft += data;
|
|
1313
|
+
tui.requestRender();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
return; // swallow other keys while editing
|
|
1317
|
+
}
|
|
1318
|
+
const row = rows[cursor];
|
|
1319
|
+
// Withdraw (Esc / Ctrl+C).
|
|
1320
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
1321
|
+
finish({ kind: "withdraw" });
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
// Space or Enter: toggle at the cursor (tier or item), or
|
|
1325
|
+
// confirm on the Confirm row.
|
|
1326
|
+
if (matchesKey(data, "space") || kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
|
|
1327
|
+
if (row.kind === "confirm") {
|
|
1328
|
+
confirmIfPossible();
|
|
1329
|
+
} else {
|
|
1330
|
+
toggleAt(row);
|
|
1331
|
+
tui.requestRender();
|
|
1332
|
+
}
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
// a / Ctrl+A: select all.
|
|
1336
|
+
if (data === "a" || matchesKey(data, "ctrl+a")) {
|
|
1337
|
+
selection = planSelectAll(proposal, selection);
|
|
1338
|
+
tui.requestRender();
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
// e: edit the note for the item under the cursor.
|
|
1342
|
+
if (data === "e") {
|
|
1343
|
+
if (row.kind === "item") {
|
|
1344
|
+
const key = planItemKey(row.tier, row.index);
|
|
1345
|
+
noteKey = key;
|
|
1346
|
+
noteDraft = notes.get(key) ?? "";
|
|
1347
|
+
tui.requestRender();
|
|
1348
|
+
} else {
|
|
1349
|
+
ctx.ui.notify("do-always: notes attach to item rows — put the cursor on an item, then press e", "info");
|
|
1350
|
+
}
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
// Ctrl+U: clear the selection.
|
|
1354
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
1355
|
+
selection = planSelectionClear();
|
|
1356
|
+
tui.requestRender();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
// Navigation (wraps at the edges).
|
|
1360
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
|
|
1361
|
+
cursor = cursor === 0 ? rows.length - 1 : cursor - 1;
|
|
1362
|
+
tui.requestRender();
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
|
|
1366
|
+
cursor = cursor === rows.length - 1 ? 0 : cursor + 1;
|
|
1367
|
+
tui.requestRender();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (matchesKey(data, "home")) {
|
|
1371
|
+
cursor = 0;
|
|
1372
|
+
tui.requestRender();
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (matchesKey(data, "end")) {
|
|
1376
|
+
cursor = rows.length - 1;
|
|
1377
|
+
tui.requestRender();
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
1383
|
+
// Wheel: move the cursor one row (the scroll window follows) —
|
|
1384
|
+
// the same behavior as the task selector.
|
|
1385
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
1386
|
+
if (noteKey !== null) return { handled: true };
|
|
1387
|
+
const delta = event.wheelDelta < 0 ? -1 : 1;
|
|
1388
|
+
const next = Math.max(0, Math.min(rows.length - 1, cursor + delta));
|
|
1389
|
+
if (next === cursor) return { handled: true };
|
|
1390
|
+
cursor = next;
|
|
1391
|
+
return { handled: true, render: true };
|
|
1392
|
+
}
|
|
1393
|
+
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
|
1394
|
+
if (noteKey !== null) return { handled: true }; // clicks are swallowed while editing
|
|
1395
|
+
const row = rowLine.get(event.y);
|
|
1396
|
+
if (!row) return undefined;
|
|
1397
|
+
if (row.kind === "confirm") {
|
|
1398
|
+
if (event.type === "press") confirmIfPossible();
|
|
1399
|
+
return { handled: true };
|
|
1400
|
+
}
|
|
1401
|
+
if (event.type === "press") {
|
|
1402
|
+
toggleAt(row);
|
|
1403
|
+
return { handled: true, render: true };
|
|
1404
|
+
}
|
|
1405
|
+
return { handled: true };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
return { render, handleInput, handleMouse, invalidate: () => {} };
|
|
1409
|
+
});
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
|
|
965
1413
|
// The in-flight chain's report file: its path (absolute + relative for
|
|
966
1414
|
// display), the precomputed header (deferred — written together with the
|
|
967
1415
|
// first step section, so a chain that dies before that leaves no
|
|
@@ -1232,6 +1680,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1232
1680
|
clearTimeout(pendingSummaryTimer);
|
|
1233
1681
|
pendingSummaryTimer = null;
|
|
1234
1682
|
}
|
|
1683
|
+
// A new run began before the captured plan proposal was offered (the
|
|
1684
|
+
// user typed a prompt right after the Plan run ended) — it is stale.
|
|
1685
|
+
pendingProposal = null;
|
|
1686
|
+
pendingPlanRaw = null;
|
|
1235
1687
|
// The run actually began — time the step for the report.
|
|
1236
1688
|
if (chainReport) chainReport.stepStartedAt = new Date();
|
|
1237
1689
|
// Fill-first: step 1 left the editor and is running — update the
|
|
@@ -1261,6 +1713,69 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1261
1713
|
return "completed";
|
|
1262
1714
|
}
|
|
1263
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* The message with every fenced plan block removed from its text parts,
|
|
1718
|
+
* or null when nothing changed. Strips per part: a fence spanning two
|
|
1719
|
+
* parts is left in place (the questionnaire parser works on the joined
|
|
1720
|
+
* text, so its behavior is unaffected by that edge case).
|
|
1721
|
+
*/
|
|
1722
|
+
function stripPlanFromMessage(message: MessageEndEvent["message"]): MessageEndEvent["message"] | null {
|
|
1723
|
+
if (message.role !== "assistant") return null;
|
|
1724
|
+
// Assistant content is a parts array (text / thinking / toolCall);
|
|
1725
|
+
// only text parts can carry the plan fence.
|
|
1726
|
+
const content = message.content;
|
|
1727
|
+
if (!Array.isArray(content)) return null;
|
|
1728
|
+
let removed = false;
|
|
1729
|
+
const parts = content.map((part) => {
|
|
1730
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
1731
|
+
const { text, removed: partRemoved } = stripPlanBlocks(part.text);
|
|
1732
|
+
if (partRemoved) {
|
|
1733
|
+
removed = true;
|
|
1734
|
+
return { ...part, text };
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
return part;
|
|
1738
|
+
});
|
|
1739
|
+
return removed ? { ...message, content: parts } : null;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Hide the plan block: while a single auto-run Plan task is in flight
|
|
1744
|
+
* (its prompt carried PLAN_OUTPUT_INSTRUCTION), capture the reply's raw
|
|
1745
|
+
* text for the questionnaire, then — in the TUI — replace the finalized
|
|
1746
|
+
* message with the plan block(s) stripped out. The runtime applies the
|
|
1747
|
+
* replacement in place, so the stripped text is what the model sees in
|
|
1748
|
+
* later turns and what the session file persists. In non-TUI modes the
|
|
1749
|
+
* block stays in the transcript: it is the model's only record of the
|
|
1750
|
+
* proposal, and the user may reply with item numbers to execute. Scoped
|
|
1751
|
+
* to the pending auto-run Plan task (renderTaskPrompt's gate), so a
|
|
1752
|
+
* plan-tagged JSON block in a normal conversation or in a non-Plan
|
|
1753
|
+
* auto-run's reply is never touched; `hidePlan` (per task, then global)
|
|
1754
|
+
* opts out of the strip.
|
|
1755
|
+
*/
|
|
1756
|
+
pi.on("message_end", (event) => {
|
|
1757
|
+
if (event.message.role !== "assistant" || !pendingSummaryTask) return;
|
|
1758
|
+
const task = tasks.find((t) => t.name === pendingSummaryTask);
|
|
1759
|
+
// Only the runs whose prompt carried PLAN_OUTPUT_INSTRUCTION (Plan
|
|
1760
|
+
// tasks with the questionnaire enabled — renderTaskPrompt's gate)
|
|
1761
|
+
// may have the block captured and stripped; a plan fence in any other
|
|
1762
|
+
// reply is the user's content and stays in the transcript.
|
|
1763
|
+
if (!task || !isPlanTask(task) || !(task.questionnaire ?? questionnaireEnabled)) return;
|
|
1764
|
+
const text = assistantText(event.message.content);
|
|
1765
|
+
if (!text.includes("```plan")) return;
|
|
1766
|
+
// Capture the raw (unstripped) text for the questionnaire — after the
|
|
1767
|
+
// strip below the message text no longer carries the block.
|
|
1768
|
+
pendingPlanRaw = text;
|
|
1769
|
+
// TUI-only strip: in non-TUI modes the block stays in the transcript
|
|
1770
|
+
// so the model can resolve the item-number replies the notification
|
|
1771
|
+
// offers (and the session file keeps the proposal on record).
|
|
1772
|
+
if (lastCtx?.mode !== "tui") return;
|
|
1773
|
+
// `hidePlan` (per task, then global) opts out of the strip.
|
|
1774
|
+
if (!(task.hidePlan ?? hidePlanEnabled)) return;
|
|
1775
|
+
const stripped = stripPlanFromMessage(event.message);
|
|
1776
|
+
if (stripped) return { message: stripped };
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1264
1779
|
pi.on("agent_end", (event) => {
|
|
1265
1780
|
// The agent may have changed the repo — drop the TTL context cache so
|
|
1266
1781
|
// the next action sees the new tree (a running chain keeps its own
|
|
@@ -1276,10 +1791,26 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1276
1791
|
const lastAssistant = lastAssistantMessage(event.messages);
|
|
1277
1792
|
if (lastAssistant) {
|
|
1278
1793
|
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
1279
|
-
|
|
1280
|
-
|
|
1794
|
+
if (outcome === "completed") {
|
|
1795
|
+
// Completed auto-run: capture the reply; the plan
|
|
1796
|
+
// questionnaire (or the plain summary when there is no
|
|
1797
|
+
// parseable plan block / the mode is not TUI) is offered at
|
|
1798
|
+
// agent_settled, when the session is fully idle. The
|
|
1799
|
+
// message_end handler stripped the plan block from the
|
|
1800
|
+
// transcript, so the message here no longer carries it — the
|
|
1801
|
+
// raw capture is the parse source (the message text is the
|
|
1802
|
+
// fallback when no capture exists).
|
|
1803
|
+
pendingProposal = {
|
|
1804
|
+
taskName: pendingSummaryTask,
|
|
1805
|
+
text: pendingPlanRaw ?? assistantText(lastAssistant.content),
|
|
1806
|
+
};
|
|
1807
|
+
} else {
|
|
1808
|
+
const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
|
|
1809
|
+
lastCtx?.ui.notify(`do-always: ${summary}`, "info");
|
|
1810
|
+
}
|
|
1281
1811
|
}
|
|
1282
1812
|
pendingSummaryTask = null;
|
|
1813
|
+
pendingPlanRaw = null;
|
|
1283
1814
|
return;
|
|
1284
1815
|
}
|
|
1285
1816
|
if (!chainWaiter) return;
|
|
@@ -1325,9 +1856,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1325
1856
|
}
|
|
1326
1857
|
}
|
|
1327
1858
|
});
|
|
1859
|
+
// The settle that follows a chain run's agent_end (or the
|
|
1860
|
+
// failed-to-start grace timer): settle the waiter there, not in
|
|
1861
|
+
// agent_end, because agent_end can fire while a queued follow-up is
|
|
1862
|
+
// still pending — the settle is the point where the session is
|
|
1863
|
+
// truly idle. A settle with no waiter is a plain user turn (or the
|
|
1864
|
+
// settle of a completed auto-run, which offers its captured reply).
|
|
1328
1865
|
pi.on("agent_settled", () => {
|
|
1329
|
-
if (
|
|
1330
|
-
|
|
1866
|
+
if (chainWaiter) {
|
|
1867
|
+
settleChainWaiter(chainWaiter.started ? (chainWaiter.outcome ?? "completed") : "failed-to-start");
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
// A completed auto-run task may have a captured reply to offer (the
|
|
1871
|
+
// plan questionnaire in TUI, the summary elsewhere). Skip when a chain
|
|
1872
|
+
// is active — the questionnaire is only for single auto-runs.
|
|
1873
|
+
if (pendingProposal && !chainActive) {
|
|
1874
|
+
const captured = pendingProposal;
|
|
1875
|
+
pendingProposal = null;
|
|
1876
|
+
void offerPlanProposal(captured, lastCtx);
|
|
1877
|
+
}
|
|
1331
1878
|
});
|
|
1332
1879
|
|
|
1333
1880
|
/**
|
|
@@ -1403,6 +1950,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1403
1950
|
const config = loadConfig(ctx.cwd, onError);
|
|
1404
1951
|
tasks = config.tasks;
|
|
1405
1952
|
reportEnabled = config.report;
|
|
1953
|
+
questionnaireEnabled = config.questionnaire;
|
|
1954
|
+
hidePlanEnabled = config.hidePlan;
|
|
1406
1955
|
refreshVisible(ctx.cwd, await getContext(ctx.cwd));
|
|
1407
1956
|
registerShortcut(config.shortcut, onError);
|
|
1408
1957
|
});
|
|
@@ -1528,8 +2077,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1528
2077
|
const details = formatSelectedCommits(selected);
|
|
1529
2078
|
const strings = toPromptContext(context);
|
|
1530
2079
|
const prompt = /\{\{\s*selected_commits\s*\}\}/.test(chosen.prompt)
|
|
1531
|
-
?
|
|
1532
|
-
: `${
|
|
2080
|
+
? renderTaskPrompt(chosen, { ...strings, selected_commits: details })
|
|
2081
|
+
: `${renderTaskPrompt(chosen, strings)}\n\nSelected commits:\n${details}`;
|
|
1533
2082
|
|
|
1534
2083
|
pendingSummaryTask = chosen.name;
|
|
1535
2084
|
if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
|
|
@@ -1554,7 +2103,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1554
2103
|
}
|
|
1555
2104
|
// Render with the same context the selector/preview used, so what the
|
|
1556
2105
|
// user saw is exactly what gets injected.
|
|
1557
|
-
const prompt =
|
|
2106
|
+
const prompt = renderTaskPrompt(task, toPromptContext(context));
|
|
1558
2107
|
if (shouldAutoRun(task)) {
|
|
1559
2108
|
// Fire-and-forget: sendUserMessage returns void; the run proceeds
|
|
1560
2109
|
// independently (see the chain control notes for why).
|
|
@@ -2081,7 +2630,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2081
2630
|
const sel = itemRows[cursor.row];
|
|
2082
2631
|
if (sel) {
|
|
2083
2632
|
const wrapWidth = Math.max(10, width - 4);
|
|
2084
|
-
const wrapped = wrapTextWithAnsi(
|
|
2633
|
+
const wrapped = wrapTextWithAnsi(renderTaskPrompt(sel.task, strings), wrapWidth);
|
|
2085
2634
|
const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
|
|
2086
2635
|
const truncated = wrapped.length > PREVIEW_MAX_LINES;
|
|
2087
2636
|
lines.push("");
|
|
@@ -2429,6 +2978,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2429
2978
|
const matches = [
|
|
2430
2979
|
{ value: "list", label: "list" },
|
|
2431
2980
|
{ value: "list-details", label: "list-details" },
|
|
2981
|
+
{ value: "replan", label: "replan" },
|
|
2432
2982
|
...visible.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
|
|
2433
2983
|
].filter((c) => c.value.toLowerCase().includes(p));
|
|
2434
2984
|
return matches.length > 0 ? matches : null;
|
|
@@ -2451,6 +3001,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2451
3001
|
const config = loadConfig(ctx.cwd, onError);
|
|
2452
3002
|
tasks = config.tasks;
|
|
2453
3003
|
reportEnabled = config.report;
|
|
3004
|
+
questionnaireEnabled = config.questionnaire;
|
|
3005
|
+
hidePlanEnabled = config.hidePlan;
|
|
2454
3006
|
}
|
|
2455
3007
|
|
|
2456
3008
|
// One context per command run: shared by visibility filtering, rendering,
|
|
@@ -2475,6 +3027,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2475
3027
|
return;
|
|
2476
3028
|
}
|
|
2477
3029
|
|
|
3030
|
+
// Re-open the questionnaire for the last offered plan proposal (e.g.
|
|
3031
|
+
// after an accidental esc). Re-offers the same captured reply, so the
|
|
3032
|
+
// confirm/withdraw behavior is exactly as before.
|
|
3033
|
+
if (arg.toLowerCase() === "replan") {
|
|
3034
|
+
if (!lastProposal) {
|
|
3035
|
+
ctx.ui.notify("do-always: no plan proposal to re-open — run a Plan task (⚡) first", "info");
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
void offerPlanProposal(lastProposal, ctx);
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
2478
3042
|
if (arg.toLowerCase() === "list-details") {
|
|
2479
3043
|
// Display only — the description is metadata; selecting a task injects just its prompt.
|
|
2480
3044
|
// Render with the current context so what is shown is what gets injected.
|
|
@@ -2484,7 +3048,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
2484
3048
|
const lines = [`${i + 1}. ${t.name}`];
|
|
2485
3049
|
if (t.description) lines.push(` description: ${t.description}`);
|
|
2486
3050
|
lines.push(" prompt (this is what gets injected on select):");
|
|
2487
|
-
for (const line of
|
|
3051
|
+
for (const line of renderTaskPrompt(t, strings).split("\n")) lines.push(` ${line}`);
|
|
2488
3052
|
return lines.join("\n");
|
|
2489
3053
|
})
|
|
2490
3054
|
.join("\n\n");
|
|
@@ -67,6 +67,25 @@ export interface DoAlwaysTask {
|
|
|
67
67
|
* configs that predate this field keep working.
|
|
68
68
|
*/
|
|
69
69
|
browser?: BrowserType;
|
|
70
|
+
/**
|
|
71
|
+
* Whether the plan questionnaire is offered after this task's run: the
|
|
72
|
+
* reply's "plan" block (tiers + action items) becomes a selectable list
|
|
73
|
+
* the user confirms or withdraws. Default true (the global config
|
|
74
|
+
* "questionnaire" sets the fallback); set false to keep the plain summary
|
|
75
|
+
* notification. Only meaningful for auto-run tasks (Plan category by
|
|
76
|
+
* default).
|
|
77
|
+
*/
|
|
78
|
+
questionnaire?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Whether the raw "plan" block is hidden from the transcript after this
|
|
81
|
+
* task's run: the fenced block is stripped from the finalized reply in
|
|
82
|
+
* the TUI (the questionnaire still parses the captured raw text; in
|
|
83
|
+
* non-TUI modes the block is always kept). Default true (the global
|
|
84
|
+
* config "hidePlan" sets the fallback); set false to keep the block
|
|
85
|
+
* visible in the transcript. Only meaningful for auto-run tasks (Plan
|
|
86
|
+
* category by default).
|
|
87
|
+
*/
|
|
88
|
+
hidePlan?: boolean;
|
|
70
89
|
}
|
|
71
90
|
|
|
72
91
|
/** The set of known guard types (used for validation at parse time). */
|
|
@@ -115,6 +134,19 @@ type DoAlwaysConfig =
|
|
|
115
134
|
* the project root). Default true; set false to disable.
|
|
116
135
|
*/
|
|
117
136
|
report?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Whether completed auto-run tasks whose reply carries a "plan"
|
|
139
|
+
* block offer the selection questionnaire. Default true; set false
|
|
140
|
+
* to keep the plain summary notification.
|
|
141
|
+
*/
|
|
142
|
+
questionnaire?: boolean;
|
|
143
|
+
/**
|
|
144
|
+
* Whether the raw "plan" block is stripped from the transcript
|
|
145
|
+
* after a completed auto-run task (TUI only — in non-TUI modes
|
|
146
|
+
* the block is always kept). Default true; set false to keep the
|
|
147
|
+
* block visible in the transcript.
|
|
148
|
+
*/
|
|
149
|
+
hidePlan?: boolean;
|
|
118
150
|
};
|
|
119
151
|
|
|
120
152
|
/** Shortcut used when neither config file specifies one. */
|
|
@@ -138,6 +170,18 @@ interface ParsedDoAlwaysConfig {
|
|
|
138
170
|
* report file. undefined when the file does not set one (default: on).
|
|
139
171
|
*/
|
|
140
172
|
report: boolean | undefined;
|
|
173
|
+
/**
|
|
174
|
+
* The `questionnaire` field, if present: whether completed auto-run
|
|
175
|
+
* tasks whose reply carries a plan block offer the selection
|
|
176
|
+
* questionnaire. undefined when the file does not set one (default: on).
|
|
177
|
+
*/
|
|
178
|
+
questionnaire: boolean | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* The `hidePlan` field, if present: whether the raw plan block is
|
|
181
|
+
* stripped from the transcript after a completed auto-run task.
|
|
182
|
+
* undefined when the file does not set one (default: on).
|
|
183
|
+
*/
|
|
184
|
+
hidePlan: boolean | undefined;
|
|
141
185
|
}
|
|
142
186
|
|
|
143
187
|
import { existsSync } from "node:fs";
|
|
@@ -474,14 +518,14 @@ export function parseConfig(
|
|
|
474
518
|
data = JSON.parse(raw);
|
|
475
519
|
} catch (err) {
|
|
476
520
|
onError(`do-always: invalid JSON in ${path}: ${err}`);
|
|
477
|
-
return { tasks: [], shortcut: undefined, report: undefined };
|
|
521
|
+
return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
478
522
|
}
|
|
479
523
|
|
|
480
524
|
const list = Array.isArray(data) ? data : data?.tasks;
|
|
481
525
|
|
|
482
526
|
if (!Array.isArray(list)) {
|
|
483
527
|
onError(`do-always: ${path} must be a JSON array of tasks or {"tasks": [...]}`);
|
|
484
|
-
return { tasks: [], shortcut: undefined, report: undefined };
|
|
528
|
+
return { tasks: [], shortcut: undefined, report: undefined, questionnaire: undefined, hidePlan: undefined };
|
|
485
529
|
}
|
|
486
530
|
|
|
487
531
|
const tasks: DoAlwaysTask[] = [];
|
|
@@ -498,6 +542,8 @@ export function parseConfig(
|
|
|
498
542
|
if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
|
|
499
543
|
if (typeof t.hidden === "boolean") task.hidden = t.hidden;
|
|
500
544
|
if (typeof t.notForCommits === "boolean") task.notForCommits = t.notForCommits;
|
|
545
|
+
if (typeof t.questionnaire === "boolean") task.questionnaire = t.questionnaire;
|
|
546
|
+
if (typeof t.hidePlan === "boolean") task.hidePlan = t.hidePlan;
|
|
501
547
|
if (t.browser !== undefined) {
|
|
502
548
|
if (typeof t.browser === "string" && BROWSER_TYPES.includes(t.browser as BrowserType)) {
|
|
503
549
|
task.browser = t.browser as BrowserType;
|
|
@@ -546,8 +592,18 @@ export function parseConfig(
|
|
|
546
592
|
if (typeof data.report === "boolean") report = data.report;
|
|
547
593
|
else onError(`do-always: ignoring invalid "report" in ${path} (expected true or false)`);
|
|
548
594
|
}
|
|
595
|
+
let questionnaire: boolean | undefined;
|
|
596
|
+
if (!Array.isArray(data) && "questionnaire" in data) {
|
|
597
|
+
if (typeof data.questionnaire === "boolean") questionnaire = data.questionnaire;
|
|
598
|
+
else onError(`do-always: ignoring invalid "questionnaire" in ${path} (expected true or false)`);
|
|
599
|
+
}
|
|
600
|
+
let hidePlan: boolean | undefined;
|
|
601
|
+
if (!Array.isArray(data) && "hidePlan" in data) {
|
|
602
|
+
if (typeof data.hidePlan === "boolean") hidePlan = data.hidePlan;
|
|
603
|
+
else onError(`do-always: ignoring invalid "hidePlan" in ${path} (expected true or false)`);
|
|
604
|
+
}
|
|
549
605
|
|
|
550
|
-
return { tasks, shortcut, merge, report };
|
|
606
|
+
return { tasks, shortcut, merge, report, questionnaire, hidePlan };
|
|
551
607
|
}
|
|
552
608
|
|
|
553
609
|
const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
@@ -807,7 +863,7 @@ export function orderTasksByCategory(
|
|
|
807
863
|
*/
|
|
808
864
|
export function shouldAutoRun(task: DoAlwaysTask): boolean {
|
|
809
865
|
if (typeof task.autoRun === "boolean") return task.autoRun;
|
|
810
|
-
return (task
|
|
866
|
+
return isPlanTask(task);
|
|
811
867
|
}
|
|
812
868
|
|
|
813
869
|
/**
|
|
@@ -1491,3 +1547,352 @@ export function formatCommitReviewPrompt(commits: SelectedCommit[]): string {
|
|
|
1491
1547
|
`Summarize your findings for each commit and propose a plan for any fixes if needed. Do not make changes yet.`
|
|
1492
1548
|
);
|
|
1493
1549
|
}
|
|
1550
|
+
|
|
1551
|
+
// ── Plan proposal ────────────────────────────────────────────────────────────
|
|
1552
|
+
//
|
|
1553
|
+
// Auto-run Plan tasks end their reply with a machine-readable plan block:
|
|
1554
|
+
// a fenced code block tagged "plan" carrying the proposed action items as
|
|
1555
|
+
// JSON (summary + tiers of items). PLAN_OUTPUT_INSTRUCTION is appended to
|
|
1556
|
+
// Plan task prompts at render time so the agent emits the block;
|
|
1557
|
+
// parsePlanProposal extracts and normalizes it; the selection state machine
|
|
1558
|
+
// and the execution prompt builder are pure so they can be unit-tested
|
|
1559
|
+
// without the Pi runtime.
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Instruction appended to Plan-category task prompts at render time: it
|
|
1563
|
+
* requires the reply to end with a fenced "plan" code block containing the
|
|
1564
|
+
* proposed action items as JSON (summary + tiers of items). Kept in one
|
|
1565
|
+
* place so every Plan prompt shares the same contract.
|
|
1566
|
+
*/
|
|
1567
|
+
export const PLAN_OUTPUT_INSTRUCTION =
|
|
1568
|
+
"End your reply with a machine-readable plan block: a fenced code block tagged plan (```plan) containing JSON of exactly this shape: " +
|
|
1569
|
+
'{"summary":"one-line summary","tiers":[{"id":"P0","label":"Critical","items":[{"title":"short action","detail":"where and why (file:line if known)"}]}]}. ' +
|
|
1570
|
+
"One tier per priority level, most urgent first (P0, P1, P2, ...). " +
|
|
1571
|
+
"Each item must be one concrete, independently doable action. " +
|
|
1572
|
+
'Use an empty "tiers" array when no action is needed.';
|
|
1573
|
+
|
|
1574
|
+
/** One concrete action item proposed by a Plan run. */
|
|
1575
|
+
export interface PlanItem {
|
|
1576
|
+
/** Short action description. */
|
|
1577
|
+
title: string;
|
|
1578
|
+
/** Where and why — file:line, rationale (optional). */
|
|
1579
|
+
detail?: string;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** A priority tier grouping action items (most urgent tier first). */
|
|
1583
|
+
export interface PlanTier {
|
|
1584
|
+
/** Tier id as emitted by the agent (e.g. "P0"); defaulted by position when absent. */
|
|
1585
|
+
id: string;
|
|
1586
|
+
/** Human label (e.g. "Critical"); defaults to the id when absent. */
|
|
1587
|
+
label: string;
|
|
1588
|
+
/** The tier's action items, in the order the agent emitted them. */
|
|
1589
|
+
items: PlanItem[];
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** A parsed plan proposal: the agent's reply, normalized for selection. */
|
|
1593
|
+
export interface PlanProposal {
|
|
1594
|
+
/** One-line summary from the block (undefined when absent or blank). */
|
|
1595
|
+
summary?: string;
|
|
1596
|
+
/** Tiers in the order the agent emitted them (most urgent first). */
|
|
1597
|
+
tiers: PlanTier[];
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/** True when the task belongs to the Plan category (case-insensitive). */
|
|
1601
|
+
export function isPlanTask(task: DoAlwaysTask): boolean {
|
|
1602
|
+
return (task.category ?? "").trim().toLowerCase() === "plan";
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Matches a fenced code block whose info string is "plan" (trailing
|
|
1607
|
+
* whitespace allowed). The lazy body stops at the first closing fence.
|
|
1608
|
+
*/
|
|
1609
|
+
const PLAN_FENCE_RE = /```plan[^\S\n]*\r?\n([\s\S]*?)```/gi;
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Remove every fenced plan block from `text`, collapsing the blank lines
|
|
1613
|
+
* they leave behind and trimming the ends. The stripped text is what the
|
|
1614
|
+
* transcript shows (the message_end handler in index.ts replaces the
|
|
1615
|
+
* finalized message with it); the raw text is captured separately for the
|
|
1616
|
+
* questionnaire parser. `removed` is false when no plan fence was present
|
|
1617
|
+
* (the text is returned unchanged).
|
|
1618
|
+
*/
|
|
1619
|
+
export function stripPlanBlocks(text: string): { text: string; removed: boolean } {
|
|
1620
|
+
if (typeof text !== "string" || text === "") return { text, removed: false };
|
|
1621
|
+
if (!text.includes("```plan")) return { text, removed: false };
|
|
1622
|
+
// An unclosed fence (no closing ```) matches nothing: leave the text
|
|
1623
|
+
// alone instead of claiming a removal that didn't happen.
|
|
1624
|
+
if ([...text.matchAll(PLAN_FENCE_RE)].length === 0) return { text, removed: false };
|
|
1625
|
+
const stripped = text
|
|
1626
|
+
.replace(PLAN_FENCE_RE, "")
|
|
1627
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1628
|
+
.replace(/^\s+/, "")
|
|
1629
|
+
.replace(/\s+$/, "");
|
|
1630
|
+
return { text: stripped, removed: true };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Parse a plan proposal from a Plan run's reply text. Finds the fenced
|
|
1635
|
+
* "plan" code blocks, tries them from last to first (the agent may emit an
|
|
1636
|
+
* early malformed one and correct it), and returns the first that yields at
|
|
1637
|
+
* least one valid item. Returns null when there is no plan block, the JSON
|
|
1638
|
+
* is malformed, or nothing valid survives normalization — callers then fall
|
|
1639
|
+
* back to the plain summary notification.
|
|
1640
|
+
*/
|
|
1641
|
+
export function parsePlanProposal(text: string): PlanProposal | null {
|
|
1642
|
+
if (typeof text !== "string" || text === "") return null;
|
|
1643
|
+
const fences = [...text.matchAll(PLAN_FENCE_RE)];
|
|
1644
|
+
for (let i = fences.length - 1; i >= 0; i--) {
|
|
1645
|
+
const proposal = normalizePlanJson(fences[i][1]);
|
|
1646
|
+
if (proposal) return proposal;
|
|
1647
|
+
}
|
|
1648
|
+
return null;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Why (or why not) a reply's plan block is usable for the questionnaire.
|
|
1653
|
+
* Mirrors parsePlanProposal's block precedence (last to first), so the
|
|
1654
|
+
* reported reason matches what the parser decided: "ok" is exactly the case
|
|
1655
|
+
* where parsePlanProposal returns a proposal.
|
|
1656
|
+
*/
|
|
1657
|
+
export type PlanBlockDiagnostic =
|
|
1658
|
+
| { kind: "none" } // no fenced plan block at all
|
|
1659
|
+
| { kind: "malformed"; detail: string } // the last block's JSON does not parse (detail: the parse error)
|
|
1660
|
+
| { kind: "empty" } // JSON parsed, but no valid items survived normalization
|
|
1661
|
+
| { kind: "ok"; itemCount: number };
|
|
1662
|
+
|
|
1663
|
+
export function planBlockDiagnostics(text: string): PlanBlockDiagnostic {
|
|
1664
|
+
if (typeof text !== "string" || text === "") return { kind: "none" };
|
|
1665
|
+
const fences = [...text.matchAll(PLAN_FENCE_RE)];
|
|
1666
|
+
if (fences.length === 0) return { kind: "none" };
|
|
1667
|
+
let lastError: string | null = null;
|
|
1668
|
+
let parsedButEmpty = false;
|
|
1669
|
+
for (let i = fences.length - 1; i >= 0; i--) {
|
|
1670
|
+
try {
|
|
1671
|
+
JSON.parse(fences[i][1].trim());
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
// The loop runs last-to-first, so the first error seen is the
|
|
1674
|
+
// last block's — the agent's final answer, which is the one to
|
|
1675
|
+
// report. Strip the position suffix from JSON.parse errors
|
|
1676
|
+
// ("at position N (line L column C)") so the message is readable
|
|
1677
|
+
// in a large plan block where the position number is meaningless.
|
|
1678
|
+
if (lastError === null) {
|
|
1679
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
1680
|
+
lastError = raw.replace(/\s+at\s+position\s+\d+(?:\s*\(line\s+\d+\s+column\s+\d+\))?/, "");
|
|
1681
|
+
}
|
|
1682
|
+
continue;
|
|
1683
|
+
}
|
|
1684
|
+
const proposal = normalizePlanJson(fences[i][1]);
|
|
1685
|
+
if (proposal) {
|
|
1686
|
+
const count = proposal.tiers.reduce((n, t) => n + t.items.length, 0);
|
|
1687
|
+
if (count > 0) return { kind: "ok", itemCount: count };
|
|
1688
|
+
}
|
|
1689
|
+
parsedButEmpty = true;
|
|
1690
|
+
}
|
|
1691
|
+
if (lastError !== null) return { kind: "malformed", detail: lastError };
|
|
1692
|
+
if (parsedButEmpty) return { kind: "empty" };
|
|
1693
|
+
return { kind: "malformed", detail: "invalid JSON" };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Normalize one plan block's JSON body. Accepts the tiered shape
|
|
1698
|
+
* ({tiers: [{id, label, items: [...]}]}) and a lenient flat shape
|
|
1699
|
+
* ({items: [{tier, title, ...}]}, grouped preserving first-seen tier
|
|
1700
|
+
* order). Invalid entries are dropped; a result with no valid items is
|
|
1701
|
+
* null.
|
|
1702
|
+
*/
|
|
1703
|
+
function normalizePlanJson(raw: string): PlanProposal | null {
|
|
1704
|
+
let data: unknown;
|
|
1705
|
+
try {
|
|
1706
|
+
data = JSON.parse(raw.trim());
|
|
1707
|
+
} catch {
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
|
|
1711
|
+
const obj = data as Record<string, unknown>;
|
|
1712
|
+
const summary =
|
|
1713
|
+
typeof obj.summary === "string" && obj.summary.trim() !== "" ? obj.summary.trim() : undefined;
|
|
1714
|
+
const tiers: PlanTier[] = [];
|
|
1715
|
+
if (Array.isArray(obj.tiers)) {
|
|
1716
|
+
obj.tiers.forEach((t, i) => {
|
|
1717
|
+
const tier = normalizePlanTier(t, i);
|
|
1718
|
+
if (tier) tiers.push(tier);
|
|
1719
|
+
});
|
|
1720
|
+
} else if (Array.isArray(obj.items)) {
|
|
1721
|
+
const byTier = new Map<string, PlanTier>();
|
|
1722
|
+
for (const entry of obj.items) {
|
|
1723
|
+
const item = normalizePlanItem(entry);
|
|
1724
|
+
if (!item) continue;
|
|
1725
|
+
const tierField =
|
|
1726
|
+
typeof entry === "object" && entry !== null
|
|
1727
|
+
? (entry as Record<string, unknown>).tier
|
|
1728
|
+
: undefined;
|
|
1729
|
+
const id = typeof tierField === "string" && tierField.trim() !== "" ? tierField.trim() : "P0";
|
|
1730
|
+
if (!byTier.has(id)) byTier.set(id, { id, label: id, items: [] });
|
|
1731
|
+
byTier.get(id)!.items.push(item);
|
|
1732
|
+
}
|
|
1733
|
+
for (const tier of byTier.values()) tiers.push(tier);
|
|
1734
|
+
}
|
|
1735
|
+
if (tiers.length === 0) return null;
|
|
1736
|
+
return { summary, tiers };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/** Normalize one tier entry; null when it carries no valid items. */
|
|
1740
|
+
function normalizePlanTier(raw: unknown, index: number): PlanTier | null {
|
|
1741
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
1742
|
+
const obj = raw as Record<string, unknown>;
|
|
1743
|
+
const items: PlanItem[] = [];
|
|
1744
|
+
if (Array.isArray(obj.items)) {
|
|
1745
|
+
for (const entry of obj.items) {
|
|
1746
|
+
const item = normalizePlanItem(entry);
|
|
1747
|
+
if (item) items.push(item);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (items.length === 0) return null;
|
|
1751
|
+
const id = typeof obj.id === "string" && obj.id.trim() !== "" ? obj.id.trim() : `P${index}`;
|
|
1752
|
+
const label = typeof obj.label === "string" && obj.label.trim() !== "" ? obj.label.trim() : id;
|
|
1753
|
+
return { id, label, items };
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Normalize one item entry: a plain string is a title-only item; an object
|
|
1758
|
+
* needs a non-blank string "title" ("detail" is optional). Null otherwise.
|
|
1759
|
+
*/
|
|
1760
|
+
function normalizePlanItem(raw: unknown): PlanItem | null {
|
|
1761
|
+
if (typeof raw === "string") {
|
|
1762
|
+
const title = raw.trim();
|
|
1763
|
+
return title === "" ? null : { title };
|
|
1764
|
+
}
|
|
1765
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
1766
|
+
const obj = raw as Record<string, unknown>;
|
|
1767
|
+
const title = typeof obj.title === "string" ? obj.title.trim() : "";
|
|
1768
|
+
if (title === "") return null;
|
|
1769
|
+
const item: PlanItem = { title };
|
|
1770
|
+
const detail = typeof obj.detail === "string" ? obj.detail.trim() : "";
|
|
1771
|
+
if (detail !== "") item.detail = detail;
|
|
1772
|
+
return item;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ── Plan questionnaire selection ─────────────────────────────────────────────
|
|
1776
|
+
//
|
|
1777
|
+
// The user's selection in the plan questionnaire: which item keys are
|
|
1778
|
+
// checked. A key is "tierIndex:itemIndex" (0-based positions in the
|
|
1779
|
+
// normalized proposal). Pure state — every operation returns a new Set,
|
|
1780
|
+
// never mutating the input (same style as the chain state).
|
|
1781
|
+
|
|
1782
|
+
/** The checked item keys of one questionnaire. */
|
|
1783
|
+
export type PlanSelection = Set<string>;
|
|
1784
|
+
|
|
1785
|
+
/** An empty selection. */
|
|
1786
|
+
export function planSelectionClear(): PlanSelection {
|
|
1787
|
+
return new Set();
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** The selection key of one item (0-based tier and item positions). */
|
|
1791
|
+
export function planItemKey(tierIndex: number, itemIndex: number): string {
|
|
1792
|
+
return `${tierIndex}:${itemIndex}`;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** Toggle one item's membership. */
|
|
1796
|
+
export function planToggleItem(selection: PlanSelection, key: string): PlanSelection {
|
|
1797
|
+
const next = new Set(selection);
|
|
1798
|
+
if (next.has(key)) next.delete(key);
|
|
1799
|
+
else next.add(key);
|
|
1800
|
+
return next;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Toggle a whole tier: fully selected → clear all its items; none or
|
|
1805
|
+
* partial → select all of them. Returns the new selection and whether the
|
|
1806
|
+
* tier ended up selected. Unknown tier index: the selection is unchanged.
|
|
1807
|
+
*/
|
|
1808
|
+
export function planToggleTier(
|
|
1809
|
+
proposal: PlanProposal,
|
|
1810
|
+
tierIndex: number,
|
|
1811
|
+
selection: PlanSelection,
|
|
1812
|
+
): { selection: PlanSelection; selected: boolean } {
|
|
1813
|
+
const tier = proposal.tiers[tierIndex];
|
|
1814
|
+
if (!tier) return { selection, selected: false };
|
|
1815
|
+
const keys = tier.items.map((_, i) => planItemKey(tierIndex, i));
|
|
1816
|
+
const allSelected = keys.every((k) => selection.has(k));
|
|
1817
|
+
const next = new Set(selection);
|
|
1818
|
+
if (allSelected) for (const k of keys) next.delete(k);
|
|
1819
|
+
else for (const k of keys) next.add(k);
|
|
1820
|
+
return { selection: next, selected: !allSelected };
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** Select every item in the proposal. */
|
|
1824
|
+
export function planSelectAll(proposal: PlanProposal, selection: PlanSelection): PlanSelection {
|
|
1825
|
+
const next = new Set(selection);
|
|
1826
|
+
proposal.tiers.forEach((tier, ti) => {
|
|
1827
|
+
tier.items.forEach((_, ii) => next.add(planItemKey(ti, ii)));
|
|
1828
|
+
});
|
|
1829
|
+
return next;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** One tier's aggregate state: no items, some, or all items selected. */
|
|
1833
|
+
export function planTierState(
|
|
1834
|
+
proposal: PlanProposal,
|
|
1835
|
+
tierIndex: number,
|
|
1836
|
+
selection: PlanSelection,
|
|
1837
|
+
): "none" | "partial" | "all" {
|
|
1838
|
+
const tier = proposal.tiers[tierIndex];
|
|
1839
|
+
if (!tier) return "none";
|
|
1840
|
+
let count = 0;
|
|
1841
|
+
tier.items.forEach((_, ii) => {
|
|
1842
|
+
if (selection.has(planItemKey(tierIndex, ii))) count++;
|
|
1843
|
+
});
|
|
1844
|
+
if (count === 0) return "none";
|
|
1845
|
+
if (count === tier.items.length) return "all";
|
|
1846
|
+
return "partial";
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/** A selected item with its tier, for display and the execution prompt. */
|
|
1850
|
+
export interface PlanSelectionEntry {
|
|
1851
|
+
tier: PlanTier;
|
|
1852
|
+
item: PlanItem;
|
|
1853
|
+
/** A user note added in the questionnaire for this item, if any. */
|
|
1854
|
+
note?: string;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* The selected items in execution order: tier order, item order within a
|
|
1859
|
+
* tier. Empty when nothing is selected. When `notes` is given, a non-empty
|
|
1860
|
+
* note for a selected item (keyed by planItemKey) is carried on the entry.
|
|
1861
|
+
*/
|
|
1862
|
+
export function planSelectedItems(
|
|
1863
|
+
proposal: PlanProposal,
|
|
1864
|
+
selection: PlanSelection,
|
|
1865
|
+
notes?: ReadonlyMap<string, string>,
|
|
1866
|
+
): PlanSelectionEntry[] {
|
|
1867
|
+
const out: PlanSelectionEntry[] = [];
|
|
1868
|
+
proposal.tiers.forEach((tier, ti) => {
|
|
1869
|
+
tier.items.forEach((item, ii) => {
|
|
1870
|
+
const key = planItemKey(ti, ii);
|
|
1871
|
+
if (!selection.has(key)) return;
|
|
1872
|
+
const note = notes?.get(key)?.trim();
|
|
1873
|
+
out.push(note ? { tier, item, note } : { tier, item });
|
|
1874
|
+
});
|
|
1875
|
+
});
|
|
1876
|
+
return out;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Build the follow-up prompt sent when the user confirms a questionnaire
|
|
1881
|
+
* selection. The proposal itself is already in the conversation (the Plan
|
|
1882
|
+
* run's reply), so the prompt references it and lists only the selected
|
|
1883
|
+
* items, in execution order.
|
|
1884
|
+
*/
|
|
1885
|
+
export function formatPlanExecutionPrompt(selected: PlanSelectionEntry[], taskName: string): string {
|
|
1886
|
+
const lines: string[] = [
|
|
1887
|
+
`Execute the following action items from the "${taskName}" plan proposal, in exactly this order. ` +
|
|
1888
|
+
"Do ONLY these items — skip every other item from the proposal, and do not start anything else. " +
|
|
1889
|
+
"When done, summarize what you changed.",
|
|
1890
|
+
"",
|
|
1891
|
+
];
|
|
1892
|
+
selected.forEach(({ tier, item, note }, i) => {
|
|
1893
|
+
lines.push(
|
|
1894
|
+
`${i + 1}. [${tier.id}] ${item.title}${item.detail ? ` — ${item.detail}` : ""}${note ? ` [note: ${note}]` : ""}`,
|
|
1895
|
+
);
|
|
1896
|
+
});
|
|
1897
|
+
return lines.join("\n");
|
|
1898
|
+
}
|