@elinpf/dsh-ops-tool-trace 0.1.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.i18n.yaml +4 -0
- package/README.md +46 -0
- package/README.zh.md +46 -0
- package/cordis.patch.yml +1 -0
- package/lib/doctrine.d.ts +65 -0
- package/lib/index.d.ts +215 -0
- package/lib/index.js +950 -0
- package/lib/invariant.d.ts +17 -0
- package/lib/invariant.js +27 -0
- package/lib/node-status.d.ts +26 -0
- package/lib/reminders.d.ts +77 -0
- package/lib/session-forests.d.ts +67 -0
- package/lib/tree-layout.d.ts +42 -0
- package/lib/tree-layout.js +98 -0
- package/lib/types.d.ts +106 -0
- package/lib/types.js +7 -0
- package/package.json +78 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops-trace: an investigation tree tool that replaces `todo_write` in the ops preset.
|
|
3
|
+
*
|
|
4
|
+
* Agent-driven, append-only event log, tree + unique resolved convergence terminal.
|
|
5
|
+
* See `.scratch/ops-trace/research/` for the full design.
|
|
6
|
+
*
|
|
7
|
+
* @module @elinpf/dsh-ops-tool-trace
|
|
8
|
+
*/
|
|
9
|
+
import z from '@deepseek-ai/schemastery';
|
|
10
|
+
import { z as zod } from 'zod';
|
|
11
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
12
|
+
import { activeTree, NODE_STATUSES } from './node-status.js';
|
|
13
|
+
import { SessionForestStore } from './session-forests.js';
|
|
14
|
+
import { buildReminderContext, createIdleRule, createNestingRule, ReminderLatch } from './reminders.js';
|
|
15
|
+
import { HELP_TEXT, STATIC_PROMPT, TOOL_DESCRIPTION, TRIGGER_NODE_RULE, milestoneFollowUpHint, resolveGateError } from './doctrine.js';
|
|
16
|
+
import { buildTreeIndex, depthOf, flattenTree, sortChildren } from './tree-layout.js';
|
|
17
|
+
// ── Plugin identity ───────────────────────────────────────────────────────────
|
|
18
|
+
const name = 'ops-trace';
|
|
19
|
+
const inject = ['tools'];
|
|
20
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Schemastery configuration for the ops-trace tool consumer.
|
|
23
|
+
*/
|
|
24
|
+
const Config = z.object({
|
|
25
|
+
/** Idle reminder: nudge after this many steps without a trace update (default 5). */
|
|
26
|
+
idleReminderGapSteps: z.number().default(5),
|
|
27
|
+
/** Idle reminder backoff ceiling (steps): the refire gap doubles per fire up to this cap (default 40). */
|
|
28
|
+
idleReminderBackoffCeilingSteps: z.number().default(40),
|
|
29
|
+
/** Nesting reminder: fires when this many steps hang flat under milestones with nothing deeper (default 3). */
|
|
30
|
+
nestingReminderFlatSteps: z.number().default(3),
|
|
31
|
+
});
|
|
32
|
+
// ── State machine (05) ───────────────────────────────────────────────────────
|
|
33
|
+
/** Legal status transitions. Key = from-status, value = set of allowed to-statuses.
|
|
34
|
+
* Note: goal → dead_end is deliberately absent here — it is legal only for
|
|
35
|
+
* milestones (id ≠ 'goal'), and that exception lives in the execute-time
|
|
36
|
+
* validation, where the node id is known. */
|
|
37
|
+
const TRANSITIONS = {
|
|
38
|
+
goal: ['in_progress', 'done', 'resolved'],
|
|
39
|
+
pending: ['in_progress', 'done', 'dead_end'],
|
|
40
|
+
in_progress: ['done', 'dead_end'],
|
|
41
|
+
done: ['in_progress', 'dead_end', 'done'],
|
|
42
|
+
dead_end: ['in_progress'],
|
|
43
|
+
resolved: [],
|
|
44
|
+
};
|
|
45
|
+
/** Check whether a transition is legal per the 05 state machine. */
|
|
46
|
+
function canTransition(from, to) {
|
|
47
|
+
return (TRANSITIONS[from] ?? []).includes(to);
|
|
48
|
+
}
|
|
49
|
+
// ── Turn extraction (02) ─────────────────────────────────────────────────────
|
|
50
|
+
/** Extract the current turn number from the agent's session events. */
|
|
51
|
+
function currentTurn(exec) {
|
|
52
|
+
const events = exec.agent?.session?.events;
|
|
53
|
+
if (!events)
|
|
54
|
+
return 0;
|
|
55
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
56
|
+
const ev = events[i];
|
|
57
|
+
if (ev.type === 'turn/start')
|
|
58
|
+
return ev.data?.turn ?? 0;
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
function foldEvent(state, event) {
|
|
63
|
+
// Only fold tool/call events for the trace tool
|
|
64
|
+
if (event.type !== 'tool/call')
|
|
65
|
+
return state;
|
|
66
|
+
const data = event.data;
|
|
67
|
+
if (data?.name !== 'trace')
|
|
68
|
+
return state;
|
|
69
|
+
// Parse arguments JSON string
|
|
70
|
+
let args;
|
|
71
|
+
try {
|
|
72
|
+
args = typeof data.arguments === 'string' ? JSON.parse(data.arguments) : data.arguments;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Truncated/malformed arguments JSON (e.g. a hand-edited log line): skip
|
|
76
|
+
// the event — one corrupt record must not break the fold of the whole log.
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
const turn = data.turn ?? data.step ?? 0;
|
|
80
|
+
const action = args.action;
|
|
81
|
+
const trees = state?.trees ? [...state.trees] : [];
|
|
82
|
+
switch (action) {
|
|
83
|
+
case 'create_tree': {
|
|
84
|
+
// Skip if goal_title missing (failed tool call that was logged before validation)
|
|
85
|
+
if (!args.goal_title)
|
|
86
|
+
return state;
|
|
87
|
+
const newTree = {
|
|
88
|
+
nodes: [{
|
|
89
|
+
id: 'goal', title: args.goal_title, status: 'goal',
|
|
90
|
+
parent: null, turns: [turn], summary: null, detail: null, caused_by: [],
|
|
91
|
+
}],
|
|
92
|
+
resolved: false,
|
|
93
|
+
};
|
|
94
|
+
return { trees: [...trees, newTree] };
|
|
95
|
+
}
|
|
96
|
+
case 'add_step':
|
|
97
|
+
case 'add_milestone': {
|
|
98
|
+
// Skip if required fields missing (failed tool call logged before validation)
|
|
99
|
+
if (!args.id || !args.parent_id || !args.title)
|
|
100
|
+
return state;
|
|
101
|
+
const forest = state ?? { trees: [] };
|
|
102
|
+
const tree = activeTree(forest);
|
|
103
|
+
if (!tree)
|
|
104
|
+
return state;
|
|
105
|
+
// Skip if node id already exists (duplicate of a successful call)
|
|
106
|
+
if (tree.nodes.some(n => n.id === args.id))
|
|
107
|
+
return state;
|
|
108
|
+
// Skip if parent doesn't exist
|
|
109
|
+
if (!tree.nodes.some(n => n.id === args.parent_id))
|
|
110
|
+
return state;
|
|
111
|
+
const kind = action === 'add_milestone' ? 'milestone' : 'step';
|
|
112
|
+
const updatedTree = {
|
|
113
|
+
...tree,
|
|
114
|
+
nodes: [...tree.nodes, {
|
|
115
|
+
id: args.id, title: args.title,
|
|
116
|
+
status: kind === 'milestone' ? 'goal' : 'pending',
|
|
117
|
+
parent: args.parent_id, turns: [turn], summary: null,
|
|
118
|
+
detail: args.detail ?? null, caused_by: [],
|
|
119
|
+
}],
|
|
120
|
+
};
|
|
121
|
+
return replaceTree(forest, tree, updatedTree);
|
|
122
|
+
}
|
|
123
|
+
case 'start':
|
|
124
|
+
case 'complete':
|
|
125
|
+
case 'abandon':
|
|
126
|
+
case 'reopen': {
|
|
127
|
+
const forest = state ?? { trees: [] };
|
|
128
|
+
const tree = activeTree(forest);
|
|
129
|
+
if (!tree)
|
|
130
|
+
return state;
|
|
131
|
+
const newStatus = action === 'start' || action === 'reopen' ? 'in_progress'
|
|
132
|
+
: action === 'complete' ? 'done'
|
|
133
|
+
: 'dead_end';
|
|
134
|
+
const nodeIds = Array.isArray(args.ids) ? args.ids : (args.id ? [args.id] : []);
|
|
135
|
+
if (nodeIds.length === 0)
|
|
136
|
+
return state;
|
|
137
|
+
let updatedTree = tree;
|
|
138
|
+
for (const nid of nodeIds) {
|
|
139
|
+
updatedTree = updateNodeInTree(updatedTree, nid, turn, (n) => {
|
|
140
|
+
n.status = newStatus;
|
|
141
|
+
if (action === 'complete' && args.summary)
|
|
142
|
+
n.summary = args.summary;
|
|
143
|
+
}) ?? updatedTree;
|
|
144
|
+
}
|
|
145
|
+
return replaceTree(forest, tree, updatedTree);
|
|
146
|
+
}
|
|
147
|
+
case 'resolve': {
|
|
148
|
+
if (!args.summary)
|
|
149
|
+
return state;
|
|
150
|
+
const forest = state ?? { trees: [] };
|
|
151
|
+
const tree = activeTree(forest);
|
|
152
|
+
if (!tree)
|
|
153
|
+
return state;
|
|
154
|
+
const targetId = args.id ?? 'goal';
|
|
155
|
+
if (targetId !== 'goal') {
|
|
156
|
+
// Non-goal resolve = positive close of one node — complete semantics.
|
|
157
|
+
// The logged event keeps the model's word ('resolve'); the fold maps
|
|
158
|
+
// it to the same state change complete would make and never closes
|
|
159
|
+
// the tree. This also repairs replay of historically REJECTED
|
|
160
|
+
// resolve(m1) calls: they were logged before validation and the old
|
|
161
|
+
// id-ignoring fold would have closed the whole tree on replay.
|
|
162
|
+
const updatedTree = updateNodeInTree(tree, targetId, turn, (n) => {
|
|
163
|
+
n.status = 'done';
|
|
164
|
+
n.summary = args.summary ?? null;
|
|
165
|
+
});
|
|
166
|
+
if (!updatedTree)
|
|
167
|
+
return state;
|
|
168
|
+
return replaceTree(forest, tree, updatedTree);
|
|
169
|
+
}
|
|
170
|
+
// Mirror of the execute-time hard gate: a resolve(goal) rejected for
|
|
171
|
+
// undecided nodes is still logged (the framework appends the tool/call
|
|
172
|
+
// event before execute runs) — without this check, replay would close
|
|
173
|
+
// a tree the tool refused to close. force is honored because accepted
|
|
174
|
+
// forced resolves carry it in the logged args.
|
|
175
|
+
if (!args.force && tree.nodes.some((n) => n.parent !== null && n.status !== 'done' && n.status !== 'dead_end' && n.status !== 'resolved')) {
|
|
176
|
+
return state;
|
|
177
|
+
}
|
|
178
|
+
const updatedTree = updateNodeInTree(tree, 'goal', turn, (n) => {
|
|
179
|
+
n.status = 'resolved';
|
|
180
|
+
n.summary = args.summary ?? null;
|
|
181
|
+
});
|
|
182
|
+
if (!updatedTree)
|
|
183
|
+
return state;
|
|
184
|
+
const resolvedTree = { ...updatedTree, resolved: true };
|
|
185
|
+
return replaceTree(forest, tree, resolvedTree);
|
|
186
|
+
}
|
|
187
|
+
case 'link': {
|
|
188
|
+
const forest = state ?? { trees: [] };
|
|
189
|
+
const tree = activeTree(forest);
|
|
190
|
+
if (!tree)
|
|
191
|
+
return state;
|
|
192
|
+
const links = Array.isArray(args.links) ? args.links : (args.id && args.caused_by ? [{ id: args.id, caused_by: args.caused_by }] : []);
|
|
193
|
+
if (links.length === 0)
|
|
194
|
+
return state;
|
|
195
|
+
// Validate all links have required fields and nodes exist
|
|
196
|
+
const validLinks = links.filter(link => link.id && link.caused_by
|
|
197
|
+
&& tree.nodes.some(n => n.id === link.id)
|
|
198
|
+
&& tree.nodes.some(n => n.id === link.caused_by));
|
|
199
|
+
if (validLinks.length === 0)
|
|
200
|
+
return state;
|
|
201
|
+
let updatedTree = tree;
|
|
202
|
+
for (const link of validLinks) {
|
|
203
|
+
updatedTree = updateNodeInTree(updatedTree, link.id, turn, (n) => {
|
|
204
|
+
if (!n.caused_by.includes(link.caused_by)) {
|
|
205
|
+
n.caused_by = [...n.caused_by, link.caused_by];
|
|
206
|
+
}
|
|
207
|
+
}) ?? updatedTree;
|
|
208
|
+
}
|
|
209
|
+
return replaceTree(forest, tree, updatedTree);
|
|
210
|
+
}
|
|
211
|
+
default:
|
|
212
|
+
return state;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Replace one tree in the forest (by reference identity). */
|
|
216
|
+
function replaceTree(forest, old, updated) {
|
|
217
|
+
return { trees: forest.trees.map(t => t === old ? updated : t) };
|
|
218
|
+
}
|
|
219
|
+
/** Pure helper: copy nodes, find target, create new copy without mutating the original. */
|
|
220
|
+
function updateNodeInTree(tree, nodeId, turn, mutate) {
|
|
221
|
+
let found = false;
|
|
222
|
+
const nodes = tree.nodes.map((n) => {
|
|
223
|
+
if (n.id !== nodeId)
|
|
224
|
+
return n;
|
|
225
|
+
found = true;
|
|
226
|
+
const copy = { ...n, turns: n.turns.includes(turn) ? n.turns : [...n.turns, turn] };
|
|
227
|
+
mutate(copy);
|
|
228
|
+
return copy;
|
|
229
|
+
});
|
|
230
|
+
return found ? { ...tree, nodes } : null;
|
|
231
|
+
}
|
|
232
|
+
// ── Summary builder (06: advisor, not gatekeeper) ───────────────────────────
|
|
233
|
+
function buildSummary(tree) {
|
|
234
|
+
const nodes = tree?.nodes ?? [];
|
|
235
|
+
const counts = {
|
|
236
|
+
goal: 0, pending: 0, in_progress: 0, done: 0, dead_end: 0, resolved: 0,
|
|
237
|
+
};
|
|
238
|
+
for (const n of nodes)
|
|
239
|
+
counts[n.status]++;
|
|
240
|
+
// Incomplete = not done, not dead_end, not resolved, and not the goal node.
|
|
241
|
+
// The goal node is structural, not "incomplete".
|
|
242
|
+
const incomplete = nodes
|
|
243
|
+
.filter((n) => n.parent !== null && n.status !== 'done' && n.status !== 'dead_end' && n.status !== 'resolved')
|
|
244
|
+
.map((n) => ({ id: n.id, title: n.title, status: n.status }));
|
|
245
|
+
const warning = incomplete.length > 0 && tree?.resolved
|
|
246
|
+
? `${incomplete.length} node(s) still incomplete`
|
|
247
|
+
: null;
|
|
248
|
+
return { total: nodes.length, counts, incomplete, warning };
|
|
249
|
+
}
|
|
250
|
+
// ── Tool description & doctrine ─────────────────────────────────────────────
|
|
251
|
+
// The doctrine sentences live in src/doctrine.ts — one home per idea; the
|
|
252
|
+
// tool description, help text, system-prompt core, and reminders all compose
|
|
253
|
+
// from it.
|
|
254
|
+
// ── Projection schema (validates the view for client transport) ─────────────
|
|
255
|
+
// Exported so tests/contract.spec.ts can assert the three node-shape
|
|
256
|
+
// declarations (TreeNode interface, this schema, treeNodeJsonSchema) agree.
|
|
257
|
+
export const treeNodeSchema = zod.object({
|
|
258
|
+
id: zod.string(),
|
|
259
|
+
title: zod.string(),
|
|
260
|
+
status: zod.enum(NODE_STATUSES),
|
|
261
|
+
parent: zod.string().nullable(),
|
|
262
|
+
turns: zod.array(zod.number()),
|
|
263
|
+
summary: zod.string().nullable(),
|
|
264
|
+
detail: zod.string().nullable(),
|
|
265
|
+
caused_by: zod.array(zod.string()),
|
|
266
|
+
});
|
|
267
|
+
const _treeNodeMatchesSchema = true;
|
|
268
|
+
void _treeNodeMatchesSchema;
|
|
269
|
+
/**
|
|
270
|
+
* JSON-schema shape of one node, for the tool's output contract. The third
|
|
271
|
+
* declaration of the node shape (after the TreeNode interface and
|
|
272
|
+
* treeNodeSchema above) — its status enum derives from NODE_STATUSES, and
|
|
273
|
+
* tests/contract.spec.ts asserts all three field sets agree.
|
|
274
|
+
*/
|
|
275
|
+
export const treeNodeJsonSchema = {
|
|
276
|
+
type: 'object',
|
|
277
|
+
additionalProperties: false,
|
|
278
|
+
properties: {
|
|
279
|
+
id: { type: 'string', required: true },
|
|
280
|
+
title: { type: 'string', required: true },
|
|
281
|
+
status: { type: 'string', required: true, enum: [...NODE_STATUSES] },
|
|
282
|
+
parent: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
283
|
+
turns: { type: 'array', required: true, items: { type: 'number' } },
|
|
284
|
+
summary: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
285
|
+
detail: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
286
|
+
caused_by: { type: 'array', required: true, items: { type: 'string' } },
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
const treeStateSchema = zod.object({
|
|
290
|
+
nodes: zod.array(treeNodeSchema),
|
|
291
|
+
resolved: zod.boolean(),
|
|
292
|
+
});
|
|
293
|
+
const forestStateSchema = zod.object({
|
|
294
|
+
trees: zod.array(treeStateSchema),
|
|
295
|
+
});
|
|
296
|
+
const traceProjectionSchema = zod.union([forestStateSchema, zod.null()]);
|
|
297
|
+
/**
|
|
298
|
+
* The shared projection definition, registered host-plane by ops-trace-ui
|
|
299
|
+
* (the panel's package) and consumed here through snapshots. One home for
|
|
300
|
+
* key/schema/fold/stateVersion so the two packages can never drift apart.
|
|
301
|
+
*/
|
|
302
|
+
export const traceProjection = {
|
|
303
|
+
key: 'trace',
|
|
304
|
+
schema: traceProjectionSchema,
|
|
305
|
+
init: () => null,
|
|
306
|
+
apply: foldEvent,
|
|
307
|
+
view: (s) => s,
|
|
308
|
+
// v4: resolve on a non-goal node folds to complete semantics (was: id
|
|
309
|
+
// ignored, always closed the tree).
|
|
310
|
+
// v5: resolve(goal) without force folds only when every non-root node is
|
|
311
|
+
// decided — mirrors the execute-time hard gate so replay cannot close a
|
|
312
|
+
// tree the tool refused to close. Old snapshots must be rebuilt.
|
|
313
|
+
stateVersion: 5,
|
|
314
|
+
};
|
|
315
|
+
// ── Tree renderers (model-visible output) ────────────────────────────────────
|
|
316
|
+
/** Status → emoji for compact rendering. */
|
|
317
|
+
const STATUS_LABEL = {
|
|
318
|
+
pending: 'pending',
|
|
319
|
+
in_progress: 'in_progress',
|
|
320
|
+
done: 'done',
|
|
321
|
+
dead_end: 'dead_end',
|
|
322
|
+
goal: '',
|
|
323
|
+
resolved: 'resolved',
|
|
324
|
+
};
|
|
325
|
+
// Sibling ordering, tree indexing, depth, and DFS flattening live in
|
|
326
|
+
// src/tree-layout.ts — shared verbatim with the web client, so the human
|
|
327
|
+
// sees the same layout the model sees.
|
|
328
|
+
/**
|
|
329
|
+
* Compact render: tree characters, one line per node, id + status + title.
|
|
330
|
+
* No detail/summary/turns. New node marked with *.
|
|
331
|
+
*/
|
|
332
|
+
function renderCompact(value, newNodeId) {
|
|
333
|
+
if (!value || !value.tree || !value.tree.nodes || value.tree.nodes.length === 0) {
|
|
334
|
+
return 'No tree — call create_tree first.';
|
|
335
|
+
}
|
|
336
|
+
const tree = value.tree;
|
|
337
|
+
const summary = value.summary;
|
|
338
|
+
const { children, root } = buildTreeIndex(tree.nodes);
|
|
339
|
+
const lines = [];
|
|
340
|
+
// Summary line
|
|
341
|
+
if (summary) {
|
|
342
|
+
const parts = [];
|
|
343
|
+
const c = summary.counts || {};
|
|
344
|
+
if (c.done)
|
|
345
|
+
parts.push(`${c.done} done`);
|
|
346
|
+
if (c.in_progress)
|
|
347
|
+
parts.push(`${c.in_progress} in_progress`);
|
|
348
|
+
if (c.pending)
|
|
349
|
+
parts.push(`${c.pending} pending`);
|
|
350
|
+
if (c.dead_end)
|
|
351
|
+
parts.push(`${c.dead_end} dead_end`);
|
|
352
|
+
if (tree.resolved)
|
|
353
|
+
parts.push('resolved');
|
|
354
|
+
if (summary.warning)
|
|
355
|
+
parts.push('WARN: ' + summary.warning);
|
|
356
|
+
if (parts.length > 0) {
|
|
357
|
+
lines.push(parts.join(' | '));
|
|
358
|
+
lines.push('');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function renderNode(node, prefix, isLast) {
|
|
362
|
+
const label = STATUS_LABEL[node.status] || '';
|
|
363
|
+
const isNew = node.id === newNodeId ? '*' : '';
|
|
364
|
+
const labelStr = label ? `${label} ` : '';
|
|
365
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
366
|
+
lines.push(`${prefix}${connector}${isNew}${node.id}: ${labelStr}${node.title}`);
|
|
367
|
+
const kids = sortChildren(children[node.id] || []);
|
|
368
|
+
const childPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
369
|
+
for (let i = 0; i < kids.length; i++) {
|
|
370
|
+
renderNode(kids[i], childPrefix, i === kids.length - 1);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (root)
|
|
374
|
+
renderNode(root, '', true);
|
|
375
|
+
return lines.join('\n');
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Full render: includes detail, summary, and turns for each node.
|
|
379
|
+
* Used by the `view` action (default format).
|
|
380
|
+
*/
|
|
381
|
+
function renderFull(value) {
|
|
382
|
+
if (!value || !value.tree || !value.tree.nodes || value.tree.nodes.length === 0) {
|
|
383
|
+
return 'No tree — call create_tree first.';
|
|
384
|
+
}
|
|
385
|
+
const tree = value.tree;
|
|
386
|
+
const { children, root } = buildTreeIndex(tree.nodes);
|
|
387
|
+
const lines = [];
|
|
388
|
+
function renderNode(node, prefix, isLast) {
|
|
389
|
+
const label = STATUS_LABEL[node.status] || '';
|
|
390
|
+
const labelStr = label ? `${label} ` : '';
|
|
391
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
392
|
+
const turnStr = node.turns?.length ? ` (turn ${node.turns.join(',')})` : '';
|
|
393
|
+
let line = `${prefix}${connector}${node.id}: ${labelStr}${node.title}${turnStr}`;
|
|
394
|
+
// Inline detail (creation rationale)
|
|
395
|
+
if (node.detail) {
|
|
396
|
+
line += ` detail: ${node.detail}`;
|
|
397
|
+
}
|
|
398
|
+
// Inline caused_by
|
|
399
|
+
if (node.caused_by.length > 0) {
|
|
400
|
+
line += ` ← caused_by: ${node.caused_by.join(', ')}`;
|
|
401
|
+
}
|
|
402
|
+
// Inline summary
|
|
403
|
+
if (node.summary) {
|
|
404
|
+
line += ` summary: ${node.summary}`;
|
|
405
|
+
}
|
|
406
|
+
lines.push(line);
|
|
407
|
+
const indent = prefix + (isLast ? ' ' : '│ ');
|
|
408
|
+
const kids = sortChildren(children[node.id] || []);
|
|
409
|
+
for (let i = 0; i < kids.length; i++) {
|
|
410
|
+
renderNode(kids[i], indent, i === kids.length - 1);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (root)
|
|
414
|
+
renderNode(root, '', true);
|
|
415
|
+
if (tree.resolved)
|
|
416
|
+
lines.push('resolved');
|
|
417
|
+
return lines.join('\n');
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Indented-outline render: one line per node, two spaces per depth, no
|
|
421
|
+
* connectors or detail — the tree shape at a glance. Used by the `view`
|
|
422
|
+
* action with format=tree. Shares flattenTree/depthOf with the web panel
|
|
423
|
+
* (src/tree-layout.ts), so both audiences see the same order.
|
|
424
|
+
*/
|
|
425
|
+
function renderIndentedTree(value) {
|
|
426
|
+
if (!value || !value.tree || !value.tree.nodes || value.tree.nodes.length === 0) {
|
|
427
|
+
return 'No tree — call create_tree first.';
|
|
428
|
+
}
|
|
429
|
+
const nodes = value.tree.nodes;
|
|
430
|
+
const listed = new Set(nodes.map((n) => n.id));
|
|
431
|
+
const cache = {};
|
|
432
|
+
const lines = flattenTree(nodes).map((n) => {
|
|
433
|
+
// Orphans (parent filtered out by status_filter, or missing) render at depth 0.
|
|
434
|
+
const depth = n.parent !== null && !listed.has(n.parent) ? 0 : depthOf(nodes, n.id, cache);
|
|
435
|
+
const label = STATUS_LABEL[n.status] || '';
|
|
436
|
+
const labelStr = label ? `${label} ` : '';
|
|
437
|
+
return `${' '.repeat(depth)}${n.id}: ${labelStr}${n.title}`;
|
|
438
|
+
});
|
|
439
|
+
if (value.tree.resolved)
|
|
440
|
+
lines.push('resolved');
|
|
441
|
+
return lines.join('\n');
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Render a single line of statistics.
|
|
445
|
+
*/
|
|
446
|
+
function renderStats(value) {
|
|
447
|
+
if (!value || !value.summary)
|
|
448
|
+
return '';
|
|
449
|
+
const summary = value.summary;
|
|
450
|
+
const parts = [];
|
|
451
|
+
parts.push(`${summary.total || 0} nodes`);
|
|
452
|
+
const c = summary.counts || {};
|
|
453
|
+
if (c.done)
|
|
454
|
+
parts.push(`${c.done} done`);
|
|
455
|
+
if (c.in_progress)
|
|
456
|
+
parts.push(`${c.in_progress} in_progress`);
|
|
457
|
+
if (c.pending)
|
|
458
|
+
parts.push(`${c.pending} pending`);
|
|
459
|
+
if (c.dead_end)
|
|
460
|
+
parts.push(`${c.dead_end} dead_end`);
|
|
461
|
+
if (value.tree?.resolved)
|
|
462
|
+
parts.push('resolved');
|
|
463
|
+
if (summary.warning)
|
|
464
|
+
parts.push('WARN: ' + summary.warning);
|
|
465
|
+
return parts.join(' | ');
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Render a single node line (inline caused_by + summary).
|
|
469
|
+
*/
|
|
470
|
+
function renderNodeLine(node, marker) {
|
|
471
|
+
const label = STATUS_LABEL[node.status] || '';
|
|
472
|
+
const labelStr = label ? `${label} ` : '';
|
|
473
|
+
let line = `${marker} ${node.id}: ${labelStr}${node.title}`;
|
|
474
|
+
if (node.detail) {
|
|
475
|
+
line += ` detail: ${node.detail}`;
|
|
476
|
+
}
|
|
477
|
+
if (node.caused_by.length > 0) {
|
|
478
|
+
line += ` ← caused_by: ${node.caused_by.join(', ')}`;
|
|
479
|
+
}
|
|
480
|
+
if (node.summary) {
|
|
481
|
+
line += ` summary: ${node.summary}`;
|
|
482
|
+
}
|
|
483
|
+
return line;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Decide what to render based on the action.
|
|
487
|
+
* - view: full tree with all details
|
|
488
|
+
* - create_tree: full compact tree (tree is tiny — just goal node)
|
|
489
|
+
* - add_step/add_milestone: increment — new node + parent + stats
|
|
490
|
+
* - start/complete/abandon/reopen: increment — changed node + stats
|
|
491
|
+
* - link: increment — changed node (with new caused_by) + stats
|
|
492
|
+
* - resolve: increment — goal node (resolved) + stats
|
|
493
|
+
*/
|
|
494
|
+
function renderOutput(args, value) {
|
|
495
|
+
const action = args?.action;
|
|
496
|
+
// help: full usage documentation, no tree needed
|
|
497
|
+
if (action === 'help')
|
|
498
|
+
return HELP_TEXT;
|
|
499
|
+
// view: full tree by default; format=tree renders the indented outline
|
|
500
|
+
if (action === 'view') {
|
|
501
|
+
return args?.format === 'tree' ? renderIndentedTree(value) : renderFull(value);
|
|
502
|
+
}
|
|
503
|
+
// create_tree: tree is just 1 node, return it
|
|
504
|
+
if (action === 'create_tree') {
|
|
505
|
+
return renderCompact(value, undefined);
|
|
506
|
+
}
|
|
507
|
+
// Incremental output for all other actions
|
|
508
|
+
if (!value || !value.tree)
|
|
509
|
+
return 'No tree — call create_tree first.';
|
|
510
|
+
const tree = value.tree;
|
|
511
|
+
const stats = renderStats(value);
|
|
512
|
+
const lines = [];
|
|
513
|
+
if (action === 'add_step' || action === 'add_milestone') {
|
|
514
|
+
// Show new node + parent
|
|
515
|
+
const newId = value.new_node;
|
|
516
|
+
if (newId) {
|
|
517
|
+
const node = tree.nodes.find((n) => n.id === newId);
|
|
518
|
+
if (node) {
|
|
519
|
+
lines.push(renderNodeLine(node, '+'));
|
|
520
|
+
if (node.parent) {
|
|
521
|
+
lines.push(` parent: ${node.parent}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else if (action === 'start' || action === 'complete' || action === 'abandon' || action === 'reopen') {
|
|
527
|
+
// Show changed node id + new status only (no summary for brevity)
|
|
528
|
+
const ids = Array.isArray(args.ids) ? args.ids : (args.id ? [args.id] : []);
|
|
529
|
+
for (const nid of ids) {
|
|
530
|
+
const node = tree.nodes.find((n) => n.id === nid);
|
|
531
|
+
if (node) {
|
|
532
|
+
const label = STATUS_LABEL[node.status] || '';
|
|
533
|
+
lines.push(`= ${nid}: ${label}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else if (action === 'link') {
|
|
538
|
+
// Show changed nodes with their new caused_by (id + caused_by only)
|
|
539
|
+
const links = Array.isArray(args.links) ? args.links : [{ id: args.id, caused_by: args.caused_by }];
|
|
540
|
+
for (const link of links) {
|
|
541
|
+
lines.push(`~ ${link.id} ← caused_by: ${link.caused_by}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
else if (action === 'resolve') {
|
|
545
|
+
// goal: tree closure; non-goal: complete-equivalent positive close
|
|
546
|
+
const targetId = typeof args.id === 'string' && args.id !== 'goal' ? args.id : null;
|
|
547
|
+
if (targetId) {
|
|
548
|
+
const node = tree.nodes.find((n) => n.id === targetId);
|
|
549
|
+
if (node) {
|
|
550
|
+
const label = STATUS_LABEL[node.status] || '';
|
|
551
|
+
lines.push(`= ${targetId}: ${label}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
// Show resolved goal
|
|
556
|
+
lines.push('= goal: resolved');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (stats)
|
|
560
|
+
lines.push(stats);
|
|
561
|
+
// Non-blocking advisory hint (e.g. add_step flat-hang under a milestone)
|
|
562
|
+
if (value.hint)
|
|
563
|
+
lines.push(value.hint);
|
|
564
|
+
return lines.join('\n');
|
|
565
|
+
}
|
|
566
|
+
function apply(ctx, config) {
|
|
567
|
+
// ── Session projection access (09) ─────────────────────────────────────────
|
|
568
|
+
// The projection itself is registered host-plane by ops-trace-ui (the
|
|
569
|
+
// panel's package) — see its src/index.ts. Here we only capture the
|
|
570
|
+
// registry reference so the tool's execute can read the current projection
|
|
571
|
+
// state via snapshot(session) — the host-side API (not faceOf, which is
|
|
572
|
+
// client-only).
|
|
573
|
+
let projectionRegistry = null;
|
|
574
|
+
ctx.inject(['sessionProjections'], (pctx) => {
|
|
575
|
+
projectionRegistry = pctx.sessionProjections ?? null;
|
|
576
|
+
});
|
|
577
|
+
// Once-per-tree latch for the add_step flat-hang hint, keyed by
|
|
578
|
+
// session + tree position in the forest: a hint teaches the drill-down
|
|
579
|
+
// habit at the moment it matters; repeating it on every flat add is
|
|
580
|
+
// noise — the nesting reminder (pre-step) remains as the backstop.
|
|
581
|
+
const hintLatched = new Set();
|
|
582
|
+
// The store owns the in-process forest map and the seeding protocol; the
|
|
583
|
+
// projection registry only feeds it snapshots.
|
|
584
|
+
const store = new SessionForestStore((session) => {
|
|
585
|
+
if (!projectionRegistry)
|
|
586
|
+
return null;
|
|
587
|
+
return projectionRegistry.snapshot(session).values?.trace ?? null;
|
|
588
|
+
}, foldEvent, (message) => ctx.logger('ops-trace').warn(message));
|
|
589
|
+
// Clean up in-process tree state when the plugin's fiber is disposed
|
|
590
|
+
// (process restart, preset unmount); the store re-seeds from the projection
|
|
591
|
+
// on next access.
|
|
592
|
+
ctx.effect(() => () => { store.clear(); hintLatched.clear(); });
|
|
593
|
+
// ── Register model tool (06) ──────────────────────────────────────────────
|
|
594
|
+
ctx.tools.register(defineTool({
|
|
595
|
+
name: 'trace',
|
|
596
|
+
description: TOOL_DESCRIPTION,
|
|
597
|
+
parameters: {
|
|
598
|
+
action: { type: 'string', required: true, enum: [
|
|
599
|
+
'create_tree', 'add_step', 'add_milestone',
|
|
600
|
+
'start', 'complete', 'abandon', 'reopen', 'resolve', 'link', 'view', 'help',
|
|
601
|
+
], description: 'The action to perform. help returns the full usage documentation.' },
|
|
602
|
+
goal_title: { type: 'string', description: 'Title for the investigation goal (create_tree only).' },
|
|
603
|
+
id: { type: 'string', description: 'Node id. For add_step/add_milestone: the new node\'s semantic id (e.g. "ceph-full"). For start/complete/abandon/reopen: single target node. For resolve: goal = 全案收口; any other id = 正面关闭该节点(等同 complete 带 summary). For link: target node (use with caused_by).' },
|
|
604
|
+
parent_id: { type: 'string', description: `Parent node id (add_step/add_milestone only). ${TRIGGER_NODE_RULE}` },
|
|
605
|
+
title: { type: 'string', description: 'Node title (add_step/add_milestone only).' },
|
|
606
|
+
ids: {
|
|
607
|
+
type: 'array',
|
|
608
|
+
items: { type: 'string' },
|
|
609
|
+
description: 'Array of node ids for batch mode (start/complete/abandon/reopen).',
|
|
610
|
+
},
|
|
611
|
+
summary: { type: 'string', description: 'How the goal/node was resolved. Required for resolve. Optional for complete — records what was found/fixed. add_step/add_milestone 的创建依据用 detail, 不是 summary。' },
|
|
612
|
+
detail: { type: 'string', description: 'Creation rationale (add_step/add_milestone only): 假设的 "因为 Y" 分句, 或 step 的具体查证对象。' },
|
|
613
|
+
caused_by: { type: 'string', description: 'Node id that is the root cause (link only). Expresses: "id is caused by caused_by".' },
|
|
614
|
+
links: {
|
|
615
|
+
type: 'array',
|
|
616
|
+
items: {
|
|
617
|
+
type: 'object',
|
|
618
|
+
additionalProperties: false,
|
|
619
|
+
properties: {
|
|
620
|
+
id: { type: 'string', required: true },
|
|
621
|
+
caused_by: { type: 'string', required: true },
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
description: 'Batch link: array of {id, caused_by} pairs (link only).',
|
|
625
|
+
},
|
|
626
|
+
// 'goal' is structural, not a status you'd filter by.
|
|
627
|
+
status_filter: { type: 'string', enum: NODE_STATUSES.filter((s) => s !== 'goal'), description: 'Filter view to nodes of one status (view only, optional).' },
|
|
628
|
+
format: { type: 'string', enum: ['full', 'tree'], description: 'view 输出格式 (view only, optional): "full" 完整树含 detail/summary (默认); "tree" 缩进树总览, 只看形状。' },
|
|
629
|
+
force: { type: 'boolean', description: 'resolve goal 的逃生口: 还有节点未定论时强制收口(结果带 WARN), 用于调查中途放弃。仅 resolve 打在 goal 上时有效。' },
|
|
630
|
+
},
|
|
631
|
+
output: {
|
|
632
|
+
schema: {
|
|
633
|
+
type: 'object',
|
|
634
|
+
additionalProperties: false,
|
|
635
|
+
properties: {
|
|
636
|
+
tree: {
|
|
637
|
+
type: 'object',
|
|
638
|
+
additionalProperties: false,
|
|
639
|
+
required: true,
|
|
640
|
+
properties: {
|
|
641
|
+
nodes: {
|
|
642
|
+
type: 'array',
|
|
643
|
+
required: true,
|
|
644
|
+
items: treeNodeJsonSchema,
|
|
645
|
+
},
|
|
646
|
+
resolved: { type: 'boolean', required: true },
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
summary: {
|
|
650
|
+
type: 'object',
|
|
651
|
+
additionalProperties: false,
|
|
652
|
+
required: true,
|
|
653
|
+
properties: {
|
|
654
|
+
total: { type: 'integer', required: true },
|
|
655
|
+
counts: {
|
|
656
|
+
type: 'object',
|
|
657
|
+
additionalProperties: false,
|
|
658
|
+
required: true,
|
|
659
|
+
properties: Object.fromEntries(NODE_STATUSES.map((s) => [s, { type: 'integer', required: true }])),
|
|
660
|
+
},
|
|
661
|
+
incomplete: {
|
|
662
|
+
type: 'array',
|
|
663
|
+
required: true,
|
|
664
|
+
items: {
|
|
665
|
+
type: 'object',
|
|
666
|
+
additionalProperties: false,
|
|
667
|
+
properties: {
|
|
668
|
+
id: { type: 'string', required: true },
|
|
669
|
+
title: { type: 'string', required: true },
|
|
670
|
+
status: { type: 'string', required: true, enum: [...NODE_STATUSES] },
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
warning: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
new_node: { type: 'string' },
|
|
678
|
+
hint: { type: 'string' },
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
render: (args, value) => [{
|
|
682
|
+
type: 'text',
|
|
683
|
+
text: renderOutput(args, value),
|
|
684
|
+
}],
|
|
685
|
+
},
|
|
686
|
+
async execute(args, exec) {
|
|
687
|
+
const agent = exec.agent;
|
|
688
|
+
if (!agent)
|
|
689
|
+
throw new Error('trace requires an owning agent session');
|
|
690
|
+
const turn = currentTurn(exec);
|
|
691
|
+
const sessionId = agent.session?.id ?? agent.id ?? 'default';
|
|
692
|
+
const session = (agent.session ?? { id: sessionId });
|
|
693
|
+
// All state access goes through the store: it owns the map, the
|
|
694
|
+
// projection seeding, and the mutation critical section.
|
|
695
|
+
const activeNode = () => activeTree(store.current(session).forest);
|
|
696
|
+
// The command tail every mutating action shares: apply through the
|
|
697
|
+
// store's critical section, then summarize the resulting active tree.
|
|
698
|
+
const applyAndSummarize = () => {
|
|
699
|
+
const tree = activeTree(store.apply(session, args, turn));
|
|
700
|
+
return { tree, summary: buildSummary(tree) };
|
|
701
|
+
};
|
|
702
|
+
switch (args.action) {
|
|
703
|
+
case 'create_tree': {
|
|
704
|
+
if (!args.goal_title)
|
|
705
|
+
throw new Error('trace: goal_title is required for create_tree');
|
|
706
|
+
return applyAndSummarize();
|
|
707
|
+
}
|
|
708
|
+
case 'add_step':
|
|
709
|
+
case 'add_milestone': {
|
|
710
|
+
const tree = activeNode();
|
|
711
|
+
if (!tree)
|
|
712
|
+
throw new Error('trace: no tree — call create_tree first');
|
|
713
|
+
if (!args.parent_id)
|
|
714
|
+
throw new Error('trace: parent_id is required');
|
|
715
|
+
if (!args.title)
|
|
716
|
+
throw new Error('trace: title is required');
|
|
717
|
+
if (!args.id)
|
|
718
|
+
throw new Error('trace: id is required');
|
|
719
|
+
const parent = tree.nodes.find((n) => n.id === args.parent_id);
|
|
720
|
+
if (!parent)
|
|
721
|
+
throw new Error(`trace: parent node "${args.parent_id}" not found`);
|
|
722
|
+
if (tree.nodes.some((n) => n.id === args.id)) {
|
|
723
|
+
throw new Error(`trace: node id "${args.id}" already exists`);
|
|
724
|
+
}
|
|
725
|
+
// Soft hint (never a rejection): flat-hanging a follow-up step
|
|
726
|
+
// under a milestone loses the drill-down chain. Milestones carry
|
|
727
|
+
// no kind marker — they are indistinguishable from steps once they
|
|
728
|
+
// leave the 'goal' status — so this fires on goal-status parents,
|
|
729
|
+
// which is the common case (milestones stay 'goal' until judged).
|
|
730
|
+
let hint;
|
|
731
|
+
if (args.action === 'add_step' && parent.id !== 'goal' && parent.status === 'goal') {
|
|
732
|
+
const doneSteps = tree.nodes.filter((n) => n.parent === parent.id && n.status === 'done' && n.summary !== null);
|
|
733
|
+
if (doneSteps.length > 0) {
|
|
734
|
+
// Once per tree — the tree's position in the forest is stable
|
|
735
|
+
// for its lifetime; a new tree (previous one resolved) earns
|
|
736
|
+
// its own single hint.
|
|
737
|
+
const forest = store.current(session).forest;
|
|
738
|
+
const latchKey = session.id + ':' + forest.trees.indexOf(tree);
|
|
739
|
+
if (!hintLatched.has(latchKey)) {
|
|
740
|
+
hintLatched.add(latchKey);
|
|
741
|
+
hint = milestoneFollowUpHint(doneSteps.map((n) => n.id));
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const result = applyAndSummarize();
|
|
746
|
+
result.new_node = args.id;
|
|
747
|
+
if (hint)
|
|
748
|
+
result.hint = hint;
|
|
749
|
+
return result;
|
|
750
|
+
}
|
|
751
|
+
case 'start':
|
|
752
|
+
case 'complete':
|
|
753
|
+
case 'abandon':
|
|
754
|
+
case 'reopen': {
|
|
755
|
+
const tree = activeNode();
|
|
756
|
+
if (!tree)
|
|
757
|
+
throw new Error('trace: no tree');
|
|
758
|
+
const nodeIds = Array.isArray(args.ids) ? args.ids : (args.id ? [args.id] : []);
|
|
759
|
+
if (nodeIds.length === 0)
|
|
760
|
+
throw new Error('trace: id (or ids array) is required');
|
|
761
|
+
const targetStatus = args.action === 'start' || args.action === 'reopen' ? 'in_progress'
|
|
762
|
+
: args.action === 'complete' ? 'done'
|
|
763
|
+
: 'dead_end';
|
|
764
|
+
// Validate transitions — idempotent if already at target status
|
|
765
|
+
for (const nid of nodeIds) {
|
|
766
|
+
const node = tree.nodes.find((n) => n.id === nid);
|
|
767
|
+
if (!node)
|
|
768
|
+
throw new Error(`trace: node "${nid}" not found`);
|
|
769
|
+
if (node.status === targetStatus)
|
|
770
|
+
continue; // idempotent — already at target
|
|
771
|
+
if (!canTransition(node.status, targetStatus)) {
|
|
772
|
+
// Milestones share the root's initial status 'goal' but are
|
|
773
|
+
// falsifiable hypotheses: abandon (goal → dead_end) is the 证伪
|
|
774
|
+
// operation and is legal for them. The root goal is not a
|
|
775
|
+
// hypothesis — closing the whole tree is resolve's job.
|
|
776
|
+
if (targetStatus === 'dead_end' && node.status === 'goal') {
|
|
777
|
+
if (node.id !== 'goal')
|
|
778
|
+
continue; // milestone 证伪 — allowed
|
|
779
|
+
throw new Error(`trace: "goal" 是整棵树的收口目标, 不能 abandon; 全案收口用 resolve(summary)`);
|
|
780
|
+
}
|
|
781
|
+
throw new Error(`trace: cannot transition "${nid}" from "${node.status}" to "${targetStatus}"`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return applyAndSummarize();
|
|
785
|
+
}
|
|
786
|
+
case 'resolve': {
|
|
787
|
+
const tree = activeNode();
|
|
788
|
+
if (!tree)
|
|
789
|
+
throw new Error('trace: no tree');
|
|
790
|
+
if (!args.summary)
|
|
791
|
+
throw new Error('trace: summary is required for resolve');
|
|
792
|
+
const targetId = args.id ?? 'goal';
|
|
793
|
+
if (targetId !== 'goal') {
|
|
794
|
+
// resolve on a non-goal node = positive close of that node, the
|
|
795
|
+
// exact semantics of complete (resolve is the domain-language
|
|
796
|
+
// intuition; complete stays as the alias). Tree closure happens
|
|
797
|
+
// only when id is 'goal' (below), byte-for-byte unchanged.
|
|
798
|
+
const node = tree.nodes.find((n) => n.id === targetId);
|
|
799
|
+
if (!node)
|
|
800
|
+
throw new Error(`trace: node "${targetId}" not found`);
|
|
801
|
+
if (node.status !== 'done' && !canTransition(node.status, 'done')) {
|
|
802
|
+
throw new Error(`trace: cannot transition "${targetId}" from "${node.status}" to "done"`);
|
|
803
|
+
}
|
|
804
|
+
return applyAndSummarize();
|
|
805
|
+
}
|
|
806
|
+
const goal = tree.nodes.find((n) => n.id === 'goal');
|
|
807
|
+
if (!goal)
|
|
808
|
+
throw new Error('trace: no goal node to resolve');
|
|
809
|
+
if (goal.status === 'resolved') {
|
|
810
|
+
return { tree, summary: buildSummary(tree) };
|
|
811
|
+
}
|
|
812
|
+
// Hard gate: every non-root node must be decided (done/dead_end)
|
|
813
|
+
// before the tree closes. The fold mirrors this check — a rejected
|
|
814
|
+
// call is still logged, and replay must not close the tree either.
|
|
815
|
+
// force: true is the explicit escape hatch (abandoning an
|
|
816
|
+
// investigation mid-way) and keeps the old WARN+allow behavior.
|
|
817
|
+
// Deliberately the ONLY closure pressure: nothing nudges at step
|
|
818
|
+
// complete time, so the gate cannot suppress drill-down.
|
|
819
|
+
if (!args.force) {
|
|
820
|
+
const undecided = buildSummary(tree).incomplete;
|
|
821
|
+
if (undecided.length > 0) {
|
|
822
|
+
throw new Error(resolveGateError(undecided));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (!canTransition(goal.status, 'resolved')) {
|
|
826
|
+
throw new Error(`trace: goal is "${goal.status}", cannot resolve`);
|
|
827
|
+
}
|
|
828
|
+
return applyAndSummarize();
|
|
829
|
+
}
|
|
830
|
+
case 'link': {
|
|
831
|
+
const tree = activeNode();
|
|
832
|
+
if (!tree)
|
|
833
|
+
throw new Error('trace: no tree');
|
|
834
|
+
const links = Array.isArray(args.links) ? args.links : [{ id: args.id, caused_by: args.caused_by }];
|
|
835
|
+
if (links.length === 0)
|
|
836
|
+
throw new Error('trace: at least one link is required');
|
|
837
|
+
// Validate all nodes exist
|
|
838
|
+
for (const link of links) {
|
|
839
|
+
if (!link.id)
|
|
840
|
+
throw new Error('trace: id is required for link');
|
|
841
|
+
if (!link.caused_by)
|
|
842
|
+
throw new Error('trace: caused_by is required for link');
|
|
843
|
+
const node = tree.nodes.find((n) => n.id === link.id);
|
|
844
|
+
if (!node)
|
|
845
|
+
throw new Error(`trace: node "${link.id}" not found`);
|
|
846
|
+
const target = tree.nodes.find((n) => n.id === link.caused_by);
|
|
847
|
+
if (!target)
|
|
848
|
+
throw new Error(`trace: node "${link.caused_by}" not found`);
|
|
849
|
+
}
|
|
850
|
+
// Check if all links already exist (idempotent)
|
|
851
|
+
const allExist = links.every(link => {
|
|
852
|
+
const node = tree.nodes.find((n) => n.id === link.id);
|
|
853
|
+
return node && node.caused_by.includes(link.caused_by);
|
|
854
|
+
});
|
|
855
|
+
if (allExist) {
|
|
856
|
+
return { tree, summary: buildSummary(tree) };
|
|
857
|
+
}
|
|
858
|
+
return applyAndSummarize();
|
|
859
|
+
}
|
|
860
|
+
case 'view': {
|
|
861
|
+
const tree = activeNode();
|
|
862
|
+
if (!tree)
|
|
863
|
+
throw new Error('trace: no tree — call create_tree first');
|
|
864
|
+
if (args.status_filter) {
|
|
865
|
+
const filtered = {
|
|
866
|
+
resolved: tree.resolved,
|
|
867
|
+
nodes: tree.nodes.filter((n) => n.parent === null || n.id === 'goal' || n.status === args.status_filter),
|
|
868
|
+
};
|
|
869
|
+
return { tree: filtered, summary: buildSummary(tree) };
|
|
870
|
+
}
|
|
871
|
+
return { tree, summary: buildSummary(tree) };
|
|
872
|
+
}
|
|
873
|
+
case 'help': {
|
|
874
|
+
// No state change — the render layer answers with HELP_TEXT.
|
|
875
|
+
const tree = activeNode();
|
|
876
|
+
return { tree: tree ?? { nodes: [], resolved: false }, summary: buildSummary(tree) };
|
|
877
|
+
}
|
|
878
|
+
default:
|
|
879
|
+
throw new Error(`trace: unknown action "${args.action}"`);
|
|
880
|
+
}
|
|
881
|
+
// Unreachable — all cases return directly
|
|
882
|
+
throw new Error('trace: unreachable');
|
|
883
|
+
},
|
|
884
|
+
presentCall: (args) => {
|
|
885
|
+
const action = args.action;
|
|
886
|
+
const title = action === 'create_tree' ? 'Create investigation tree'
|
|
887
|
+
: action === 'add_step' ? 'Add step'
|
|
888
|
+
: action === 'add_milestone' ? 'Add milestone'
|
|
889
|
+
: action === 'resolve' ? 'Resolve'
|
|
890
|
+
: action.charAt(0).toUpperCase() + action.slice(1);
|
|
891
|
+
return { card: 'generic', title, kind: 'other', rawInput: args };
|
|
892
|
+
},
|
|
893
|
+
}));
|
|
894
|
+
// ── System prompt section ──────────────────────────────────────────────────
|
|
895
|
+
// Minimal always-on core, composed in src/doctrine.ts: what the tree is,
|
|
896
|
+
// the trigger-node rule, and a pointer to the full documentation.
|
|
897
|
+
const staticText = STATIC_PROMPT;
|
|
898
|
+
// Register methodology and reminders through ops-prompts. The preset mounts
|
|
899
|
+
// the group's plugins concurrently, so a one-shot ctx.get can lose the race
|
|
900
|
+
// against ops-prompts' provide — fall back to ctx.inject, which defers until
|
|
901
|
+
// the service arrives.
|
|
902
|
+
//
|
|
903
|
+
// Reminder rules are pure functions of a derived ReminderContext; the latches
|
|
904
|
+
// live here because they belong to the registration, not the rule.
|
|
905
|
+
// Idle backoff: the refire gap doubles after each fire with a 40-step
|
|
906
|
+
// ceiling (5, 10, 20, 40, 40, ...) — short sessions behave exactly as
|
|
907
|
+
// before (first two fires unchanged), long investigations keep a
|
|
908
|
+
// low-frequency nudge forever. createIdleRule resets the backoff when the
|
|
909
|
+
// agent answers a reminder, so each quiet stretch starts over at 5. The
|
|
910
|
+
// fire cap is a formality against runaway state, not the anti-spam
|
|
911
|
+
// mechanism — the ceiling is.
|
|
912
|
+
const idleRule = createIdleRule(new ReminderLatch((fires) => Math.min(config.idleReminderGapSteps * 2 ** (fires - 1), config.idleReminderBackoffCeilingSteps), 1000), config.idleReminderGapSteps);
|
|
913
|
+
const nestingRule = createNestingRule(new ReminderLatch(1, 5), config.nestingReminderFlatSteps);
|
|
914
|
+
const runRule = (rule) => (agent) => {
|
|
915
|
+
const ctx = buildReminderContext(agent, store);
|
|
916
|
+
return ctx === null ? null : rule(ctx);
|
|
917
|
+
};
|
|
918
|
+
// registerMethodology/registerReminder return disposers — route them
|
|
919
|
+
// through ctx.effect so the methodology section and the reminder closures
|
|
920
|
+
// (which capture this plugin's store/latches) leave the ops-prompts
|
|
921
|
+
// registry when this plugin's fiber is disposed (HMR reload / preset
|
|
922
|
+
// unmount), instead of outliving it.
|
|
923
|
+
const registerThroughHandle = (rctx, opsPrompts) => {
|
|
924
|
+
rctx.effect(() => {
|
|
925
|
+
const disposeMethodology = opsPrompts.registerMethodology({
|
|
926
|
+
name: 'trace:usage',
|
|
927
|
+
order: 240,
|
|
928
|
+
text: staticText,
|
|
929
|
+
});
|
|
930
|
+
const disposeIdle = opsPrompts.registerReminder({ name: 'trace:idle', check: runRule(idleRule) });
|
|
931
|
+
const disposeNesting = opsPrompts.registerReminder({ name: 'trace:nesting', check: runRule(nestingRule) });
|
|
932
|
+
return () => { disposeMethodology(); disposeIdle(); disposeNesting(); };
|
|
933
|
+
});
|
|
934
|
+
};
|
|
935
|
+
const immediateOpsPrompts = ctx.get('opsPrompts');
|
|
936
|
+
if (immediateOpsPrompts !== undefined) {
|
|
937
|
+
registerThroughHandle(ctx, immediateOpsPrompts);
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
// No direct systemPrompt fallback: this plugin is preset-plane only
|
|
941
|
+
// (ops-trace-ui owns the host-plane projection + panel). When ops-prompts
|
|
942
|
+
// is genuinely absent, the tool description and the help action still
|
|
943
|
+
// carry the usage documentation.
|
|
944
|
+
ctx.inject(['opsPrompts'], (pctx) => {
|
|
945
|
+
registerThroughHandle(pctx, pctx.opsPrompts);
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
export { Config, apply, inject, name, foldEvent, traceProjectionSchema };
|
|
950
|
+
//# sourceMappingURL=index.js.map
|