@yeaft/webchat-agent 0.1.762 → 0.1.766

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,324 @@
1
+ /**
2
+ * tool-usage.js — ToolUsageStats: per-tool call/error/latency counters
3
+ * with throttled persistence to ~/.yeaft/stats/tool-usage.json.
4
+ *
5
+ * Purpose: answer "which tools are defined but never called?" and
6
+ * "which tools dominate latency / error rate?" without dragging a
7
+ * full APM stack in. The engine emits `tool_exec` events containing
8
+ * `{name, durationMs, isError}` after every tool call — `record()` is
9
+ * the hook point.
10
+ *
11
+ * Storage shape (JSON file):
12
+ * ```
13
+ * {
14
+ * schema: 1,
15
+ * tools: {
16
+ * "<name>": {
17
+ * callCount: number,
18
+ * errorCount: number,
19
+ * totalDurationMs: number,
20
+ * durations: number[], // ring of last N durations for p50/p95
21
+ * lastCalledAt: ISO8601 string,
22
+ * lastError: string | null
23
+ * }
24
+ * }
25
+ * }
26
+ * ```
27
+ *
28
+ * Persistence policy: write to a `.tmp` sibling then `rename()` so we
29
+ * never leave a half-written JSON on disk. Throttled — flush after
30
+ * every `flushEveryNRecords` records OR if `flushIntervalMs` has
31
+ * passed since the last write, whichever comes first. `flush()` is
32
+ * exposed for shutdown paths.
33
+ *
34
+ * Latency math: p50/p95 from a sorted copy of the durations ring.
35
+ * Small samples (< 100 per tool) are good enough for a "noticed this
36
+ * tool is slow" signal — not a billing-grade SLO.
37
+ */
38
+
39
+ import { promises as fsp, existsSync, readFileSync } from 'node:fs';
40
+ import { dirname, join } from 'node:path';
41
+ import { homedir } from 'node:os';
42
+
43
+ const DEFAULT_RING_SIZE = 100;
44
+ const DEFAULT_FLUSH_EVERY_N = 20;
45
+ const DEFAULT_FLUSH_INTERVAL_MS = 30_000;
46
+ const SCHEMA_VERSION = 1;
47
+
48
+ function defaultStatsPath() {
49
+ return join(homedir(), '.yeaft', 'stats', 'tool-usage.json');
50
+ }
51
+
52
+ function emptyToolRecord() {
53
+ return {
54
+ callCount: 0,
55
+ errorCount: 0,
56
+ totalDurationMs: 0,
57
+ durations: [],
58
+ lastCalledAt: null,
59
+ lastError: null,
60
+ };
61
+ }
62
+
63
+ function percentile(sortedAsc, p) {
64
+ if (!Array.isArray(sortedAsc) || sortedAsc.length === 0) return 0;
65
+ const idx = Math.min(
66
+ sortedAsc.length - 1,
67
+ Math.max(0, Math.floor((p / 100) * sortedAsc.length))
68
+ );
69
+ return sortedAsc[idx];
70
+ }
71
+
72
+ export class ToolUsageStats {
73
+ /** @type {string} */
74
+ #path;
75
+ /** @type {Record<string, ReturnType<typeof emptyToolRecord>>} */
76
+ #tools;
77
+ /** @type {number} */
78
+ #ringSize;
79
+ /** @type {number} */
80
+ #flushEveryN;
81
+ /** @type {number} */
82
+ #flushIntervalMs;
83
+ /** @type {number} */
84
+ #recordsSinceFlush;
85
+ /** @type {number} */
86
+ #lastFlushAt;
87
+ /** @type {Promise<void> | null} */
88
+ #flushInFlight;
89
+
90
+ constructor({
91
+ path = defaultStatsPath(),
92
+ ringSize = DEFAULT_RING_SIZE,
93
+ flushEveryN = DEFAULT_FLUSH_EVERY_N,
94
+ flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS,
95
+ } = {}) {
96
+ this.#path = path;
97
+ this.#tools = Object.create(null);
98
+ this.#ringSize = ringSize;
99
+ this.#flushEveryN = flushEveryN;
100
+ this.#flushIntervalMs = flushIntervalMs;
101
+ this.#recordsSinceFlush = 0;
102
+ this.#lastFlushAt = 0;
103
+ this.#flushInFlight = null;
104
+ }
105
+
106
+ /**
107
+ * Synchronous load — best-effort. If the file doesn't exist or is
108
+ * corrupt, start fresh. Caller decides when to call this (usually
109
+ * once at session boot).
110
+ */
111
+ loadSync() {
112
+ try {
113
+ if (!existsSync(this.#path)) return;
114
+ const raw = readFileSync(this.#path, 'utf8');
115
+ const parsed = JSON.parse(raw);
116
+ if (parsed && typeof parsed === 'object' && parsed.tools && typeof parsed.tools === 'object') {
117
+ for (const [name, rec] of Object.entries(parsed.tools)) {
118
+ if (!rec || typeof rec !== 'object') continue;
119
+ this.#tools[name] = {
120
+ callCount: Number(rec.callCount) || 0,
121
+ errorCount: Number(rec.errorCount) || 0,
122
+ totalDurationMs: Number(rec.totalDurationMs) || 0,
123
+ durations: Array.isArray(rec.durations) ? rec.durations.slice(-this.#ringSize) : [],
124
+ lastCalledAt: typeof rec.lastCalledAt === 'string' ? rec.lastCalledAt : null,
125
+ lastError: typeof rec.lastError === 'string' ? rec.lastError : null,
126
+ };
127
+ }
128
+ }
129
+ } catch {
130
+ // Best-effort: a corrupt file shouldn't crash the agent. Start fresh.
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Async load — alternative to loadSync for code paths that prefer
136
+ * async I/O. Same best-effort semantics.
137
+ */
138
+ async load() {
139
+ try {
140
+ if (!existsSync(this.#path)) return;
141
+ const raw = await fsp.readFile(this.#path, 'utf8');
142
+ const parsed = JSON.parse(raw);
143
+ if (parsed && typeof parsed === 'object' && parsed.tools && typeof parsed.tools === 'object') {
144
+ for (const [name, rec] of Object.entries(parsed.tools)) {
145
+ if (!rec || typeof rec !== 'object') continue;
146
+ this.#tools[name] = {
147
+ callCount: Number(rec.callCount) || 0,
148
+ errorCount: Number(rec.errorCount) || 0,
149
+ totalDurationMs: Number(rec.totalDurationMs) || 0,
150
+ durations: Array.isArray(rec.durations) ? rec.durations.slice(-this.#ringSize) : [],
151
+ lastCalledAt: typeof rec.lastCalledAt === 'string' ? rec.lastCalledAt : null,
152
+ lastError: typeof rec.lastError === 'string' ? rec.lastError : null,
153
+ };
154
+ }
155
+ }
156
+ } catch {
157
+ // Best-effort.
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Record a single tool execution. Updates in-memory counters and
163
+ * may trigger a throttled persist.
164
+ *
165
+ * @param {{ name: string, durationMs?: number, isError?: boolean, errorMessage?: string }} args
166
+ */
167
+ record({ name, durationMs = 0, isError = false, errorMessage = null } = {}) {
168
+ if (typeof name !== 'string' || !name) return;
169
+ const dur = Math.max(0, Number(durationMs) || 0);
170
+ let rec = this.#tools[name];
171
+ if (!rec) {
172
+ rec = emptyToolRecord();
173
+ this.#tools[name] = rec;
174
+ }
175
+ rec.callCount += 1;
176
+ rec.totalDurationMs += dur;
177
+ rec.durations.push(dur);
178
+ if (rec.durations.length > this.#ringSize) {
179
+ rec.durations.splice(0, rec.durations.length - this.#ringSize);
180
+ }
181
+ rec.lastCalledAt = new Date().toISOString();
182
+ if (isError) {
183
+ rec.errorCount += 1;
184
+ if (typeof errorMessage === 'string' && errorMessage) {
185
+ rec.lastError = errorMessage.slice(0, 500);
186
+ }
187
+ }
188
+ this.#recordsSinceFlush += 1;
189
+ this.#maybeFlush();
190
+ }
191
+
192
+ #maybeFlush() {
193
+ const now = Date.now();
194
+ const stale = now - this.#lastFlushAt >= this.#flushIntervalMs;
195
+ const overflow = this.#recordsSinceFlush >= this.#flushEveryN;
196
+ if (!stale && !overflow) return;
197
+ // If a write is already in flight, skip — the in-memory state is
198
+ // ahead of disk, but #recordsSinceFlush will trigger the next overflow.
199
+ if (this.#flushInFlight) return;
200
+ this.#startFlush();
201
+ }
202
+
203
+ /**
204
+ * Persist current state. Writes atomically via .tmp + rename.
205
+ *
206
+ * Coalesces with any in-flight throttled flush — first awaits it (the
207
+ * in-flight write captured a *snapshot* taken before this call, so it
208
+ * may not include the latest record), then schedules a fresh singleton
209
+ * flush so the on-disk state matches the in-memory state at the moment
210
+ * this method resolves. `#flushInFlight` is the only path that ever
211
+ * opens a writer on `${path}.tmp`, so two writers never race.
212
+ */
213
+ async flush() {
214
+ if (this.#flushInFlight) {
215
+ try { await this.#flushInFlight; } catch { /* swallow */ }
216
+ }
217
+ this.#startFlush();
218
+ try { await this.#flushInFlight; } catch { /* swallow */ }
219
+ }
220
+
221
+ #startFlush() {
222
+ this.#flushInFlight = this.#doFlush().catch(() => {
223
+ // Persisted-write failures are non-fatal: in-memory state is the
224
+ // source of truth for the current session.
225
+ }).finally(() => {
226
+ this.#flushInFlight = null;
227
+ });
228
+ }
229
+
230
+ async #doFlush() {
231
+ const payload = {
232
+ schema: SCHEMA_VERSION,
233
+ writtenAt: new Date().toISOString(),
234
+ tools: this.#tools,
235
+ };
236
+ const json = JSON.stringify(payload, null, 2);
237
+ // Capture the records-since-flush count at write-start. Any records
238
+ // that land during the async write must still count toward the next
239
+ // overflow trigger, so we subtract only what we actually persisted —
240
+ // not whatever value `#recordsSinceFlush` happens to be when rename
241
+ // returns.
242
+ const flushedCount = this.#recordsSinceFlush;
243
+ const dir = dirname(this.#path);
244
+ try {
245
+ await fsp.mkdir(dir, { recursive: true });
246
+ } catch {
247
+ // mkdir failure (perm denied, etc.) — abort silently.
248
+ return;
249
+ }
250
+ const tmpPath = `${this.#path}.tmp`;
251
+ try {
252
+ await fsp.writeFile(tmpPath, json, 'utf8');
253
+ await fsp.rename(tmpPath, this.#path);
254
+ this.#lastFlushAt = Date.now();
255
+ this.#recordsSinceFlush = Math.max(0, this.#recordsSinceFlush - flushedCount);
256
+ } catch {
257
+ // Try to clean the tmp file if it dangling.
258
+ try { await fsp.unlink(tmpPath); } catch { /* swallow */ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Snapshot for CLI/UI rendering. Computes p50/p95/avg/errorRate per
264
+ * tool from the in-memory rings.
265
+ *
266
+ * @returns {Record<string, {callCount: number, errorCount: number, errorRate: number, avgMs: number, p50Ms: number, p95Ms: number, lastCalledAt: string|null, lastError: string|null}>}
267
+ */
268
+ snapshot() {
269
+ /** @type {Record<string, any>} */
270
+ const out = Object.create(null);
271
+ for (const [name, rec] of Object.entries(this.#tools)) {
272
+ const sorted = rec.durations.slice().sort((a, b) => a - b);
273
+ const avg = rec.callCount > 0 ? rec.totalDurationMs / rec.callCount : 0;
274
+ out[name] = {
275
+ callCount: rec.callCount,
276
+ errorCount: rec.errorCount,
277
+ errorRate: rec.callCount > 0 ? rec.errorCount / rec.callCount : 0,
278
+ avgMs: Math.round(avg),
279
+ p50Ms: Math.round(percentile(sorted, 50)),
280
+ p95Ms: Math.round(percentile(sorted, 95)),
281
+ lastCalledAt: rec.lastCalledAt,
282
+ lastError: rec.lastError,
283
+ };
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /**
289
+ * From a list of registered tool names, return the ones that have
290
+ * NEVER been recorded (call count == 0 or absent). Useful for the
291
+ * `yeaft-stats --unused` CLI mode — surfaces dead/defined-but-unused
292
+ * tools.
293
+ *
294
+ * @param {string[]} registeredNames
295
+ * @returns {string[]}
296
+ */
297
+ getRegisteredButUncalled(registeredNames) {
298
+ if (!Array.isArray(registeredNames)) return [];
299
+ const out = [];
300
+ for (const name of registeredNames) {
301
+ const rec = this.#tools[name];
302
+ if (!rec || rec.callCount === 0) out.push(name);
303
+ }
304
+ return out.sort();
305
+ }
306
+
307
+ /** Path used for persistence — for tests / CLI display. */
308
+ get path() {
309
+ return this.#path;
310
+ }
311
+
312
+ /**
313
+ * Reset everything — clears in-memory counters and removes the file.
314
+ * Used by the `yeaft-stats --reset` CLI mode.
315
+ */
316
+ async reset() {
317
+ this.#tools = Object.create(null);
318
+ this.#recordsSinceFlush = 0;
319
+ this.#lastFlushAt = 0;
320
+ try { await fsp.unlink(this.#path); } catch { /* swallow */ }
321
+ }
322
+ }
323
+
324
+ export default ToolUsageStats;
@@ -181,25 +181,10 @@ export function startSubAgent(agent, deps = {}) {
181
181
  */
182
182
  async function driveSubAgent(agent, subEngine, vpPersona, deps) {
183
183
  const onEvent = typeof deps.onEvent === 'function' ? deps.onEvent : null;
184
- // PR-4: lazy parent-feature lookup. The parent's web-bridge installs
185
- // an accessor on its engine right after creating the per-turn arc;
186
- // that accessor flows here as `deps.getCurrentFeatureId`. We read it
187
- // at every emit (NOT once at spawn-time) so a feature that opens AFTER
188
- // the sub-agent starts still tags later events. Returns null when the
189
- // parent is not in a feature run, in which case we leave `featureId`
190
- // unset on the forwarded event (NOT explicitly null) so the frontend
191
- // sub-agent card renders in its anchor-based fallback position.
192
- const getParentFeatureId = (typeof deps.getCurrentFeatureId === 'function')
193
- ? deps.getCurrentFeatureId
194
- : null;
195
- const wrapEvt = (evt) => {
196
- const base = { ...evt, agentId: agent.id, agentName: agent.name };
197
- if (!getParentFeatureId) return base;
198
- let fid = null;
199
- try { fid = getParentFeatureId(); } catch { fid = null; }
200
- if (fid && !base.featureId) base.featureId = fid;
201
- return base;
202
- };
184
+ // Sub-agent events are forwarded with agentId/agentName stamped on top of
185
+ // the raw engine event. (PR-4 parent-feature inheritance was removed
186
+ // 2026-05-13 along with the rest of the Feature system.)
187
+ const wrapEvt = (evt) => ({ ...evt, agentId: agent.id, agentName: agent.name });
203
188
 
204
189
  // Helper: append a user message and either start or resume.
205
190
  const dequeueNextUserPrompt = () => {
@@ -0,0 +1,42 @@
1
+ # Planning Mode
2
+
3
+ You have just entered **planning mode** for the topic below. Do NOT start
4
+ executing yet. Your job in this turn is to **think through the work and produce
5
+ a concrete plan**, then hand it off to `TodoWrite` so the steps are tracked.
6
+
7
+ ## How to think
8
+
9
+ 1. **Restate the problem in one sentence** — what success looks like, in plain
10
+ language. Verify your understanding matches what the user actually asked.
11
+ 2. **Surface the real constraints**: what's fixed (deadlines, dependencies,
12
+ APIs), what's flexible, where you'd push back if the requirement is wrong.
13
+ 3. **Identify the unknowns**: list the 2–3 things you can't decide without
14
+ more information. If any unknown blocks the whole plan, call it out — the
15
+ first step should be to resolve it.
16
+ 4. **Choose an approach**, briefly compared against one alternative. Don't
17
+ over-engineer: pick the simplest thing that handles the stated scope.
18
+ 5. **Break it into 3–7 ordered steps**. Each step should be ≤ 1 unit of work
19
+ that you (or another VP) can actually do and verify.
20
+
21
+ ## Output shape
22
+
23
+ Reply in two parts:
24
+
25
+ **Part 1 — Plan (prose, short).** 5–10 lines covering the problem, the chosen
26
+ approach, and the key risks. No filler. Skip if the topic is trivial.
27
+
28
+ **Part 2 — Call `TodoWrite`.** Convert the ordered steps into a `todos[]`
29
+ array. Status rule:
30
+ - The first concrete step → `status: "in_progress"`.
31
+ - All remaining steps → `status: "pending"`.
32
+ - Use the **imperative** form for `content` ("Write failing test"), and the
33
+ **present-continuous** form for `activeForm` ("Writing failing test").
34
+
35
+ **Do not execute the steps in this turn.** This turn ends after the `TodoWrite`
36
+ call returns. On the next turn, the user (or you) will pick up the
37
+ `in_progress` item and start work.
38
+
39
+ ## Tone
40
+
41
+ Be honest about what you don't know. A 4-step plan that admits one unknown is
42
+ worth more than a 12-step plan that pretends everything is decided.
@@ -37,6 +37,28 @@
37
37
  - Do not retry the same command without changing something
38
38
  - If a file doesn't exist, check the path and search for alternatives
39
39
 
40
+ ## Multi-Step Task Tracking (TodoWrite)
41
+
42
+ When the task you're about to do has **3+ meaningful steps**, the user
43
+ gave you a **list** of things to do, or you're starting a **non-trivial
44
+ multi-file change** — call `TodoWrite` **first** to lay out the
45
+ checklist. The user sees the items tick off in real time as you work.
46
+
47
+ How to use it:
48
+
49
+ - First call: enumerate every step with status `"pending"`, mark
50
+ exactly one as `"in_progress"`.
51
+ - Each subsequent call: rewrite the **full** list. Mark the
52
+ just-finished item `"completed"` and promote the next one to
53
+ `"in_progress"`.
54
+ - At most **one** item may be `"in_progress"` at any time.
55
+ - `content` is the imperative form (e.g. "Run tests"); `activeForm` is
56
+ the present-continuous form shown during execution (e.g. "Running
57
+ tests").
58
+
59
+ Do **not** use TodoWrite for single trivial edits, single command runs,
60
+ or pure conversational/question turns — the checklist becomes noise.
61
+
40
62
  <!-- lang:zh -->
41
63
 
42
64
  # 工具使用指引
@@ -75,3 +97,22 @@
75
97
  - 如果工具返回错误,在重试前仔细阅读错误信息
76
98
  - 不要在没有改变任何东西的情况下重试相同的命令
77
99
  - 如果文件不存在,检查路径并搜索替代方案
100
+
101
+ ## 多步骤任务追踪(TodoWrite)
102
+
103
+ 当你要做的事 **≥3 个有意义的步骤**、用户给了你一组任务、或者你即将开始
104
+ **复杂的多文件改动**时——**先**调用 `TodoWrite` 列出待办清单。用户会
105
+ 实时看到这些条目被勾选。
106
+
107
+ 使用方式:
108
+
109
+ - 第一次调用:枚举所有步骤,状态全部 `"pending"`,仅把一项标记为
110
+ `"in_progress"`。
111
+ - 之后每次调用:重写**完整**清单——把刚完成的项改成 `"completed"`,下
112
+ 一项改成 `"in_progress"`。
113
+ - 任何时刻最多只能有 **一个** `"in_progress"`。
114
+ - `content` 是命令式(如 "Run tests");`activeForm` 是执行中展示的进行
115
+ 时(如 "Running tests")。
116
+
117
+ **不要**为单条琐碎修改、单次命令执行、纯对话/问题使用 TodoWrite——清单
118
+ 反而成了噪音。
@@ -43,22 +43,20 @@ import listAgents from './list-agents.js';
43
43
  // --- P1 Routing tools (task-334d) ---
44
44
  import routeForward from './route-forward.js';
45
45
 
46
- // --- P1 Feature tools ---
47
- import {
48
- featureCreate,
49
- featureUpdate,
50
- featureList,
51
- featureGet,
52
- featureProgress,
53
- featureMemory,
54
- followupFeature,
55
- updatePlan,
56
- featureSummaryPost,
57
- } from './feature-tools.js';
46
+ // --- P1 Progress tracking ---
47
+ import todoWrite from './todo-write.js';
48
+ import startPlan from './start-plan.js';
58
49
 
59
50
  // H2.f.4: thread tools (spawnThread/switchThread/listThreads/...) deleted.
60
51
  // The agent now runs in a single conversation; multi-thread orchestration
61
52
  // has been retired across the H2.f series.
53
+ //
54
+ // Feature tools (FeatureCreate/Update/List/Get/Progress/Memory + Followup
55
+ // + UpdatePlan + feature_summary_post) and the FeatureArc auto-creation
56
+ // system were removed in 2026-05-13 — they were defined but never used in
57
+ // production, contributing ~2900 lines of dead code. The TodoWrite tool
58
+ // above replaces them as the actual progress-tracking surface the LLM
59
+ // uses for multi-step tasks.
62
60
 
63
61
  // --- P2 Auxiliary tools ---
64
62
  // task-333b L1 delete: ToolSearch and WriteStdin removed — the function-call
@@ -109,16 +107,9 @@ export const allTools = [
109
107
  // P1 Routing (task-334d)
110
108
  routeForward,
111
109
 
112
- // P1 Feature
113
- featureCreate,
114
- featureUpdate,
115
- featureList,
116
- featureGet,
117
- featureProgress,
118
- featureMemory,
119
- followupFeature,
120
- updatePlan,
121
- featureSummaryPost,
110
+ // P1 Progress tracking
111
+ todoWrite,
112
+ startPlan,
122
113
 
123
114
  // P2 Auxiliary
124
115
  jsRepl,
@@ -0,0 +1,133 @@
1
+ /**
2
+ * start-plan.js — StartPlan tool.
3
+ *
4
+ * Lightweight planning entry point inspired by Claude Code's plan mode.
5
+ * Unlike Claude Code, we do NOT swap tools or change conversation state —
6
+ * `StartPlan` is a regular tool. Its only job is to push a planning
7
+ * instruction back into the model's tool-result stream so the very next
8
+ * turn produces a structured plan plus a `TodoWrite` call.
9
+ *
10
+ * Design (locked 2026-05-13):
11
+ * - Anyone can call it; the tool description tells the LLM when to.
12
+ * - The instruction text comes from one of two places, in order:
13
+ * 1. The active VP's `planInstruction` frontmatter override
14
+ * (threaded through ctx.vpPersona.planInstruction by the
15
+ * engine; see engine.js #buildToolContext).
16
+ * 2. The default template `templates/plan-instruction.md`
17
+ * loaded at module init by prompts.js.
18
+ * - The caller may pass guiding fields (stuckAt, userProblem,
19
+ * expectedScale, additionalContext) to help the model think — these
20
+ * are echoed back in the tool result so the planning turn can read
21
+ * them without re-asking the user.
22
+ * - Output is plain text (the instruction + the echo). No side effects,
23
+ * no persistence — the LLM's next turn does the actual planning and
24
+ * calls TodoWrite to land structured steps.
25
+ *
26
+ * The expected integration is TodoWrite: after the planning turn the LLM
27
+ * issues a `TodoWrite` call enumerating the 1..N steps. The frontend
28
+ * already renders TodoWrite as a checkbox-style list, so the user sees
29
+ * the plan materialize without any new UI.
30
+ */
31
+
32
+ import { defineTool } from './types.js';
33
+ import { getDefaultPlanInstruction } from '../prompts.js';
34
+
35
+ export default defineTool({
36
+ name: 'StartPlan',
37
+ description: `Enter planning mode for a non-trivial task. Use BEFORE you start working when the request needs multiple steps, has unclear scope, or the user said "make a plan" / "think through this first".
38
+
39
+ This tool does NOT execute the work. It returns a planning instruction; on the next turn you should:
40
+ 1. Produce a short prose plan (problem, approach, risks).
41
+ 2. Call \`TodoWrite\` with the ordered steps. Mark the first concrete step "in_progress", the rest "pending".
42
+
43
+ WHEN TO USE:
44
+ - Multi-step implementation (3+ steps), refactor, or open-ended investigation.
45
+ - User explicitly asks for a plan, a TODO list, or to "think through" the work.
46
+ - You're about to start a large change and want a checkpoint before diving in.
47
+
48
+ WHEN NOT TO USE:
49
+ - Single trivial change, single command run, lookup-style question.
50
+ - Mid-execution — once you're past the first step, use TodoWrite directly.
51
+
52
+ The tool takes the topic plus optional guiding fields (stuckAt, userProblem, expectedScale, additionalContext) that help you think; they're echoed back verbatim, so don't repeat the full user request in \`topic\`.`,
53
+ parameters: {
54
+ type: 'object',
55
+ properties: {
56
+ topic: {
57
+ type: 'string',
58
+ description: 'One-sentence statement of what is being planned (e.g. "Add dark-mode toggle to UnifyPage settings").',
59
+ },
60
+ userProblem: {
61
+ type: 'string',
62
+ description: 'Optional. The underlying problem the user is trying to solve (often broader than the immediate ask).',
63
+ },
64
+ stuckAt: {
65
+ type: 'string',
66
+ description: 'Optional. If you are blocked or unsure, the specific decision or unknown that needs resolving first.',
67
+ },
68
+ expectedScale: {
69
+ type: 'string',
70
+ description: 'Optional. Rough scope estimate — number of files touched, lines of code, time horizon, etc.',
71
+ },
72
+ additionalContext: {
73
+ type: 'string',
74
+ description: 'Optional. Any other facts that shape the plan (constraints, deadlines, related prior work).',
75
+ },
76
+ },
77
+ required: ['topic'],
78
+ },
79
+ isConcurrencySafe: () => true,
80
+ isReadOnly: () => true,
81
+ async execute(input, ctx) {
82
+ const topic = typeof input?.topic === 'string' ? input.topic.trim() : '';
83
+ if (!topic) {
84
+ // Plain-text error — same shape as the success path so the LLM
85
+ // doesn't need a JSON-vs-text branch to read this tool's output.
86
+ return 'Error: topic is required (one-sentence statement of what is being planned).';
87
+ }
88
+
89
+ // Resolve the planning instruction: per-VP override first, then default.
90
+ // `ctx.vpPersona.planInstruction` is wired by engine.js #buildToolContext;
91
+ // it's the empty string when the VP has no override, or when the call is
92
+ // not VP-scoped (test ctx, sub-agent ctx without persona). We tolerate
93
+ // both shapes and fall through to the default template silently.
94
+ const language = typeof ctx?.config?.language === 'string' ? ctx.config.language : 'en';
95
+ const vpOverride = typeof ctx?.vpPersona?.planInstruction === 'string'
96
+ ? ctx.vpPersona.planInstruction.trim()
97
+ : '';
98
+ const instruction = vpOverride || getDefaultPlanInstruction(language);
99
+
100
+ // Echo the optional guiding fields back so the planning turn has them
101
+ // without re-reading the user's original message. Skip empty strings.
102
+ const echoed = {};
103
+ for (const key of ['userProblem', 'stuckAt', 'expectedScale', 'additionalContext']) {
104
+ const v = typeof input?.[key] === 'string' ? input[key].trim() : '';
105
+ if (v) echoed[key] = v;
106
+ }
107
+
108
+ // Plain text is friendlier to the LLM than JSON for an instructional
109
+ // result. The shape: a tagged instruction block, then a compact
110
+ // YAML-ish echo of the guiding fields, then a one-line nudge to land
111
+ // the plan via TodoWrite.
112
+ const lines = [];
113
+ lines.push('<plan-instruction>');
114
+ lines.push(instruction);
115
+ lines.push('</plan-instruction>');
116
+ lines.push('');
117
+ lines.push('<topic>');
118
+ lines.push(topic);
119
+ lines.push('</topic>');
120
+ if (Object.keys(echoed).length > 0) {
121
+ lines.push('');
122
+ lines.push('<guiding-context>');
123
+ for (const [k, v] of Object.entries(echoed)) {
124
+ lines.push(`${k}: ${v}`);
125
+ }
126
+ lines.push('</guiding-context>');
127
+ }
128
+ lines.push('');
129
+ lines.push('Next: produce the plan as described above, then call `TodoWrite` to land the ordered steps. Do NOT start executing the steps in this turn.');
130
+
131
+ return lines.join('\n');
132
+ },
133
+ });