@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.
- package/bin/yeaft-stats.js +165 -0
- package/connection/message-router.js +6 -9
- package/package.json +3 -2
- package/unify/cli.js +16 -0
- package/unify/dream-v2/prompts/index.js +4 -2
- package/unify/dream-v2/session-wiring.js +5 -4
- package/unify/dream-v2/triage.js +7 -10
- package/unify/engine.js +42 -88
- package/unify/groups/pre-flow.js +5 -5
- package/unify/prompts.js +35 -202
- package/unify/session.js +18 -3
- package/unify/stats/format.js +31 -0
- package/unify/stats/tool-usage.js +324 -0
- package/unify/sub-agent/runner.js +4 -19
- package/unify/templates/plan-instruction.md +42 -0
- package/unify/templates/tool-guidance.md +41 -0
- package/unify/tools/index.js +13 -22
- package/unify/tools/start-plan.js +133 -0
- package/unify/tools/todo-write.js +125 -0
- package/unify/vp/vp-crud.js +1 -0
- package/unify/vp/vp-loader.js +2 -1
- package/unify/vp/vp-store.js +8 -0
- package/unify/web-bridge.js +93 -145
- package/unify/features/store.js +0 -583
- package/unify/features/summary.js +0 -250
- package/unify/tools/feature-tools.js +0 -713
|
@@ -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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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
|
+
反而成了噪音。
|
package/unify/tools/index.js
CHANGED
|
@@ -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
|
|
47
|
-
import
|
|
48
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
});
|