@noetic-tools/context 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -3
- package/dist/context/cache-anchoring.d.ts +103 -0
- package/dist/context/cache-anchoring.js +273 -0
- package/dist/context/layer-lifecycle.d.ts +3 -5
- package/dist/context/layer-lifecycle.js +12 -2
- package/dist/context/layer-provides.d.ts +1 -1
- package/dist/context/layer-provides.js +1 -1
- package/dist/context/layer-usage.d.ts +18 -3
- package/dist/context/layer-usage.js +10 -1
- package/dist/context/layers/{file-reference.d.ts → filesystem.d.ts} +1 -1
- package/dist/context/layers/{file-reference.js → filesystem.js} +60 -3
- package/dist/context/layers/{history-window.d.ts → history.d.ts} +4 -4
- package/dist/context/layers/{history-window.js → history.js} +6 -6
- package/dist/context/layers/{static-content.d.ts → instructions.d.ts} +3 -2
- package/dist/context/layers/{static-content.js → instructions.js} +5 -2
- package/dist/context/layers/{observational-context.d.ts → observations.d.ts} +12 -12
- package/dist/context/layers/{observational-context.js → observations.js} +7 -7
- package/dist/context/layers/plan-prompts.d.ts +1 -1
- package/dist/context/layers/plan-prompts.js +16 -16
- package/dist/context/layers/plan-state.d.ts +31 -0
- package/dist/context/layers/plan-state.js +12 -0
- package/dist/context/layers/{plan-context.d.ts → plan.d.ts} +6 -28
- package/dist/context/layers/plan.js +697 -0
- package/dist/context/layers/{working-memory-context.d.ts → scratchpad.d.ts} +13 -13
- package/dist/context/layers/{working-memory-context.js → scratchpad.js} +8 -8
- package/dist/context/layers/steering.d.ts +1 -0
- package/dist/context/layers/steering.js +6 -1
- package/dist/context/layers/{durable-task-state.d.ts → task-state.d.ts} +20 -20
- package/dist/context/layers/{durable-task-state.js → task-state.js} +9 -9
- package/dist/context/layers/{temporal-context.d.ts → temporal.d.ts} +5 -5
- package/dist/context/layers/{temporal-context.js → temporal.js} +11 -5
- package/dist/context/layers/{tool-context-layer.d.ts → tool-calls.d.ts} +2 -2
- package/dist/context/layers/{tool-context-layer.js → tool-calls.js} +1 -1
- package/dist/context/projector.d.ts +27 -8
- package/dist/context/projector.js +42 -13
- package/dist/context/strip-unresolved.d.ts +1 -1
- package/dist/context/strip-unresolved.js +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.js +10 -10
- package/package.json +2 -2
- package/dist/context/layers/plan-context.js +0 -683
- package/dist/deprecated.d.ts +0 -43
- package/dist/deprecated.js +0 -28
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { createMessage, estimateTokens, Slot, SteeringAction, WorkflowDocumentSchema, walkWorkflow, workflowDepth, } from '@noetic-tools/types';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { layerData, layerFunction } from '../layer-provides.js';
|
|
4
|
+
import { nodeKindList, PlanStyle, renderExecuting, renderPlanning, renderTerminal, } from './plan-prompts.js';
|
|
5
|
+
import { PlanPhase } from './plan-state.js';
|
|
6
|
+
export { PlanStyle } from './plan-prompts.js';
|
|
7
|
+
export { PlanPhase } from './plan-state.js';
|
|
8
|
+
//#region Constants
|
|
9
|
+
const MAX_PRD_LENGTH = 5e4;
|
|
10
|
+
const MAX_DEPTH = 5;
|
|
11
|
+
const MAX_WORKFLOWS = 20;
|
|
12
|
+
const MAX_WORKFLOW_CHARS = 2e4;
|
|
13
|
+
const MAX_EXECUTION_LOG_ENTRIES = 10;
|
|
14
|
+
const PLAN_SLOT = Slot.PROCEDURAL - 10; // 240
|
|
15
|
+
const SCHEMA_URL = 'https://noetic.tools/schema/noetic-workflow.schema.json';
|
|
16
|
+
/** Slug rule for named workflows: lowercase alphanumeric start, then `-`/`_` allowed, max 64 chars. */
|
|
17
|
+
const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
18
|
+
const ALLOWED_TOOLS_IN_PLAN_MODE = new Set([
|
|
19
|
+
'Read',
|
|
20
|
+
'Grep',
|
|
21
|
+
'Find',
|
|
22
|
+
'Ls',
|
|
23
|
+
'AskUserQuestion',
|
|
24
|
+
'activateSkill',
|
|
25
|
+
'agent',
|
|
26
|
+
'checkAgent',
|
|
27
|
+
'sendMessage',
|
|
28
|
+
'requestPlanApproval',
|
|
29
|
+
'plan/enterPlanMode',
|
|
30
|
+
'plan/updatePrd',
|
|
31
|
+
'plan/setPlanTree',
|
|
32
|
+
'plan/setWorkflow',
|
|
33
|
+
'plan/removeWorkflow',
|
|
34
|
+
'plan/getWorkflow',
|
|
35
|
+
'plan/exitPlanMode',
|
|
36
|
+
]);
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region Helpers
|
|
39
|
+
function createDefaultState() {
|
|
40
|
+
return {
|
|
41
|
+
phase: PlanPhase.Idle,
|
|
42
|
+
prd: null,
|
|
43
|
+
planTree: null,
|
|
44
|
+
workflows: {},
|
|
45
|
+
executionLog: [],
|
|
46
|
+
version: 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Persisted state may predate the WorkflowDocument plan format (legacy FlowNode
|
|
51
|
+
* trees) or lack the workflows map. An unparseable tree resets to null — the
|
|
52
|
+
* model simply re-authors it — rather than failing init.
|
|
53
|
+
*/
|
|
54
|
+
function normalizeState(saved) {
|
|
55
|
+
const tree = saved.planTree !== null && WorkflowDocumentSchema.safeParse(saved.planTree).success
|
|
56
|
+
? saved.planTree
|
|
57
|
+
: null;
|
|
58
|
+
const workflows = {};
|
|
59
|
+
for (const [name, doc] of Object.entries(saved.workflows ?? {})) {
|
|
60
|
+
if (WorkflowDocumentSchema.safeParse(doc).success) {
|
|
61
|
+
workflows[name] = doc;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
...saved,
|
|
66
|
+
planTree: tree,
|
|
67
|
+
workflows,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* LLM tool calls frequently stringify a nested-object argument. Accept the
|
|
72
|
+
* document as either a native object or a JSON string, then validate against
|
|
73
|
+
* `WorkflowDocumentSchema`, returning a readable error summary on failure.
|
|
74
|
+
*/
|
|
75
|
+
function parseDocument(input) {
|
|
76
|
+
let candidate = input;
|
|
77
|
+
if (typeof candidate === 'string') {
|
|
78
|
+
try {
|
|
79
|
+
candidate = JSON.parse(candidate);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: 'the value is not valid JSON',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const parsed = WorkflowDocumentSchema.safeParse(candidate);
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
const issues = parsed.error.issues
|
|
91
|
+
.slice(0, 3)
|
|
92
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
93
|
+
.join('; ');
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: issues,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
doc: parsed.data,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Validates a parsed document against the layer's structural limits. Returns a
|
|
106
|
+
* readable rejection string, or null when the document passes.
|
|
107
|
+
*/
|
|
108
|
+
function checkDocumentLimits(doc, limits) {
|
|
109
|
+
if (workflowDepth(doc.root) > limits.maxDepth) {
|
|
110
|
+
return `document exceeds maximum depth of ${limits.maxDepth}`;
|
|
111
|
+
}
|
|
112
|
+
if (limits.allowedNodeKinds) {
|
|
113
|
+
const allowed = new Set(limits.allowedNodeKinds);
|
|
114
|
+
const disallowed = new Set();
|
|
115
|
+
for (const node of walkWorkflow(doc.root)) {
|
|
116
|
+
if (!allowed.has(node.kind)) {
|
|
117
|
+
disallowed.add(node.kind);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (disallowed.size > 0) {
|
|
121
|
+
return `document uses disallowed node kinds: ${[
|
|
122
|
+
...disallowed,
|
|
123
|
+
].join(', ')}. Allowed: ${limits.allowedNodeKinds.join(', ')}`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const badRefs = collectSubflowRefs(doc).filter((ref) => !WORKFLOW_NAME_RE.test(ref));
|
|
127
|
+
if (badRefs.length > 0) {
|
|
128
|
+
return `subflow refs are not valid workflow names: ${badRefs.join(', ')}. Names are lowercase slugs (a-z, 0-9, -, _), max 64 chars`;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/** Collects every named subflow ref in a document (inline subflows contribute their nested refs via the walk). */
|
|
133
|
+
function collectSubflowRefs(doc) {
|
|
134
|
+
const refs = [];
|
|
135
|
+
for (const node of walkWorkflow(doc.root)) {
|
|
136
|
+
if (node.kind === 'subflow' && node.ref) {
|
|
137
|
+
refs.push(node.ref);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return refs;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Finds refs with no matching stored workflow, across the tree and every
|
|
144
|
+
* stored workflow. Returns readable `"name" (referenced from <where>)` items.
|
|
145
|
+
*/
|
|
146
|
+
function findDanglingRefs(state) {
|
|
147
|
+
const dangling = [];
|
|
148
|
+
const defined = new Set(Object.keys(state.workflows));
|
|
149
|
+
const check = (doc, where) => {
|
|
150
|
+
for (const ref of collectSubflowRefs(doc)) {
|
|
151
|
+
if (!defined.has(ref)) {
|
|
152
|
+
dangling.push(`"${ref}" (referenced from ${where})`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
if (state.planTree) {
|
|
157
|
+
check(state.planTree, 'the plan tree');
|
|
158
|
+
}
|
|
159
|
+
for (const [name, doc] of Object.entries(state.workflows)) {
|
|
160
|
+
check(doc, `workflow "${name}"`);
|
|
161
|
+
}
|
|
162
|
+
return dangling;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Detects a reference cycle among named workflows (self-reference included).
|
|
166
|
+
* Returns the cycle path for the rejection message, or null when acyclic.
|
|
167
|
+
*/
|
|
168
|
+
function findWorkflowCycle(workflows) {
|
|
169
|
+
const edges = new Map();
|
|
170
|
+
for (const [name, doc] of Object.entries(workflows)) {
|
|
171
|
+
edges.set(name, collectSubflowRefs(doc).filter((ref) => ref in workflows));
|
|
172
|
+
}
|
|
173
|
+
const visiting = new Set();
|
|
174
|
+
const done = new Set();
|
|
175
|
+
const path = [];
|
|
176
|
+
const visit = (name) => {
|
|
177
|
+
if (done.has(name)) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (visiting.has(name)) {
|
|
181
|
+
return [
|
|
182
|
+
...path.slice(path.indexOf(name)),
|
|
183
|
+
name,
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
visiting.add(name);
|
|
187
|
+
path.push(name);
|
|
188
|
+
for (const next of edges.get(name) ?? []) {
|
|
189
|
+
const cycle = visit(next);
|
|
190
|
+
if (cycle) {
|
|
191
|
+
return cycle;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
path.pop();
|
|
195
|
+
visiting.delete(name);
|
|
196
|
+
done.add(name);
|
|
197
|
+
return null;
|
|
198
|
+
};
|
|
199
|
+
for (const name of edges.keys()) {
|
|
200
|
+
const cycle = visit(name);
|
|
201
|
+
if (cycle) {
|
|
202
|
+
return cycle;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
function buildAllowedTools(config) {
|
|
208
|
+
if (!config?.additionalAllowedTools?.length) {
|
|
209
|
+
return ALLOWED_TOOLS_IN_PLAN_MODE;
|
|
210
|
+
}
|
|
211
|
+
return new Set([
|
|
212
|
+
...ALLOWED_TOOLS_IN_PLAN_MODE,
|
|
213
|
+
...config.additionalAllowedTools,
|
|
214
|
+
]);
|
|
215
|
+
}
|
|
216
|
+
function trimExecutionLog(log) {
|
|
217
|
+
if (log.length <= MAX_EXECUTION_LOG_ENTRIES) {
|
|
218
|
+
return log;
|
|
219
|
+
}
|
|
220
|
+
return log.slice(log.length - MAX_EXECUTION_LOG_ENTRIES);
|
|
221
|
+
}
|
|
222
|
+
const RECALL_RENDERERS = {
|
|
223
|
+
[PlanPhase.Planning]: (state, options) => renderPlanning(state, {
|
|
224
|
+
style: options.style,
|
|
225
|
+
schemaUrl: SCHEMA_URL,
|
|
226
|
+
allowedTools: options.allowedTools,
|
|
227
|
+
allowedNodeKinds: options.allowedNodeKinds,
|
|
228
|
+
subAgentTool: options.subAgentTool,
|
|
229
|
+
extra: options.extra,
|
|
230
|
+
maxChars: options.maxChars,
|
|
231
|
+
}),
|
|
232
|
+
[PlanPhase.Executing]: (state, options) => renderExecuting(state, options.maxChars),
|
|
233
|
+
[PlanPhase.Completed]: (state, options) => renderTerminal(state, options.maxChars),
|
|
234
|
+
[PlanPhase.Failed]: (state, options) => renderTerminal(state, options.maxChars),
|
|
235
|
+
};
|
|
236
|
+
function resolvePlanOptions(config) {
|
|
237
|
+
return {
|
|
238
|
+
maxPrdLength: config?.maxPrdLength ?? MAX_PRD_LENGTH,
|
|
239
|
+
maxWorkflows: config?.maxWorkflows ?? MAX_WORKFLOWS,
|
|
240
|
+
maxWorkflowChars: config?.maxWorkflowChars ?? MAX_WORKFLOW_CHARS,
|
|
241
|
+
limits: {
|
|
242
|
+
maxDepth: config?.maxDepth ?? MAX_DEPTH,
|
|
243
|
+
allowedNodeKinds: config?.allowedNodeKinds,
|
|
244
|
+
},
|
|
245
|
+
allowedTools: buildAllowedTools(config),
|
|
246
|
+
style: config?.style ?? PlanStyle.Phased,
|
|
247
|
+
subAgentTool: config?.subAgentTool,
|
|
248
|
+
additionalPlanInstructions: config?.additionalPlanInstructions,
|
|
249
|
+
onEnterSession: config?.onEnterSession,
|
|
250
|
+
onExit: config?.onExit,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async function executeEnterPlanMode(args, state, options) {
|
|
254
|
+
if (state.phase === PlanPhase.Planning || state.phase === PlanPhase.Executing) {
|
|
255
|
+
return {
|
|
256
|
+
result: `Cannot enter plan mode: a plan is already active (phase "${state.phase}").`,
|
|
257
|
+
state,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const session = options.onEnterSession ? await options.onEnterSession() : null;
|
|
261
|
+
return {
|
|
262
|
+
result: 'Plan mode activated. Explore the codebase, then call plan/updatePrd.',
|
|
263
|
+
state: {
|
|
264
|
+
...state,
|
|
265
|
+
phase: PlanPhase.Planning,
|
|
266
|
+
prd: args.goal ? `# Goal\n\n${args.goal}\n` : null,
|
|
267
|
+
planTree: null,
|
|
268
|
+
workflows: {},
|
|
269
|
+
executionLog: [],
|
|
270
|
+
version: state.version + 1,
|
|
271
|
+
planSlug: session?.slug ?? null,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async function executeUpdatePrd(args, state, options) {
|
|
276
|
+
if (state.phase !== PlanPhase.Planning) {
|
|
277
|
+
return {
|
|
278
|
+
result: `Cannot update PRD: current phase is "${state.phase}". Enter plan mode first.`,
|
|
279
|
+
state,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (args.content.length > options.maxPrdLength) {
|
|
283
|
+
return {
|
|
284
|
+
result: `PRD content exceeds maximum length of ${options.maxPrdLength} characters.`,
|
|
285
|
+
state,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
result: 'PRD updated successfully.',
|
|
290
|
+
state: {
|
|
291
|
+
...state,
|
|
292
|
+
prd: args.content,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
async function executeSetPlanTree(args, state, options) {
|
|
297
|
+
if (state.phase !== PlanPhase.Planning) {
|
|
298
|
+
return {
|
|
299
|
+
result: `Cannot set plan tree: current phase is "${state.phase}". Enter plan mode first.`,
|
|
300
|
+
state,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const parsed = parseDocument(args.document);
|
|
304
|
+
if (!parsed.ok) {
|
|
305
|
+
return {
|
|
306
|
+
result: `Cannot set plan tree: ${parsed.error}. See ${SCHEMA_URL}.`,
|
|
307
|
+
state,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const limitError = checkDocumentLimits(parsed.doc, options.limits);
|
|
311
|
+
if (limitError) {
|
|
312
|
+
return {
|
|
313
|
+
result: `Cannot set plan tree: ${limitError}.`,
|
|
314
|
+
state,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const defined = new Set(Object.keys(state.workflows));
|
|
318
|
+
const dangling = [
|
|
319
|
+
...new Set(collectSubflowRefs(parsed.doc).filter((ref) => !defined.has(ref))),
|
|
320
|
+
];
|
|
321
|
+
const result = dangling.length > 0
|
|
322
|
+
? `Plan tree set. Subflow refs not yet defined: ${dangling.join(', ')} — define each with plan/setWorkflow, then call plan/exitPlanMode.`
|
|
323
|
+
: 'Plan tree set successfully. Call plan/exitPlanMode to request approval.';
|
|
324
|
+
return {
|
|
325
|
+
result,
|
|
326
|
+
state: {
|
|
327
|
+
...state,
|
|
328
|
+
planTree: parsed.doc,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
async function executeSetWorkflow(args, state, options) {
|
|
333
|
+
if (state.phase !== PlanPhase.Planning) {
|
|
334
|
+
return {
|
|
335
|
+
result: `Cannot set workflow: current phase is "${state.phase}". Enter plan mode first.`,
|
|
336
|
+
state,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (!WORKFLOW_NAME_RE.test(args.name)) {
|
|
340
|
+
return {
|
|
341
|
+
result: `Cannot set workflow: "${args.name}" is not a valid name. Use a lowercase slug (a-z, 0-9, -, _), max 64 chars, e.g. "run-tests".`,
|
|
342
|
+
state,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const replacing = args.name in state.workflows;
|
|
346
|
+
if (!replacing && Object.keys(state.workflows).length >= options.maxWorkflows) {
|
|
347
|
+
return {
|
|
348
|
+
result: `Cannot set workflow: the plan already has ${options.maxWorkflows} workflows. Remove one with plan/removeWorkflow or replace an existing name.`,
|
|
349
|
+
state,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const parsed = parseDocument(args.document);
|
|
353
|
+
if (!parsed.ok) {
|
|
354
|
+
return {
|
|
355
|
+
result: `Cannot set workflow "${args.name}": ${parsed.error}. See ${SCHEMA_URL}.`,
|
|
356
|
+
state,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const serializedLength = JSON.stringify(parsed.doc).length;
|
|
360
|
+
if (serializedLength > options.maxWorkflowChars) {
|
|
361
|
+
return {
|
|
362
|
+
result: `Cannot set workflow "${args.name}": document is ${serializedLength} chars, over the ${options.maxWorkflowChars} limit. Split it into smaller named workflows.`,
|
|
363
|
+
state,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const limitError = checkDocumentLimits(parsed.doc, options.limits);
|
|
367
|
+
if (limitError) {
|
|
368
|
+
return {
|
|
369
|
+
result: `Cannot set workflow "${args.name}": ${limitError}.`,
|
|
370
|
+
state,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const workflows = {
|
|
374
|
+
...state.workflows,
|
|
375
|
+
[args.name]: parsed.doc,
|
|
376
|
+
};
|
|
377
|
+
const names = Object.keys(workflows).sort().join(', ');
|
|
378
|
+
return {
|
|
379
|
+
result: `Workflow "${args.name}" ${replacing ? 'set (replaced previous version)' : 'created'}. ${Object.keys(workflows).length} workflow(s) defined: ${names}. Call plan/exitPlanMode when the plan is complete.`,
|
|
380
|
+
state: {
|
|
381
|
+
...state,
|
|
382
|
+
workflows,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
async function executeRemoveWorkflow(args, state) {
|
|
387
|
+
if (state.phase !== PlanPhase.Planning) {
|
|
388
|
+
return {
|
|
389
|
+
result: `Cannot remove workflow: current phase is "${state.phase}". Enter plan mode first.`,
|
|
390
|
+
state,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (!(args.name in state.workflows)) {
|
|
394
|
+
const existing = Object.keys(state.workflows).sort().join(', ') || '(none)';
|
|
395
|
+
return {
|
|
396
|
+
result: `No workflow named "${args.name}". Existing workflows: ${existing}.`,
|
|
397
|
+
state,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const workflows = {
|
|
401
|
+
...state.workflows,
|
|
402
|
+
};
|
|
403
|
+
delete workflows[args.name];
|
|
404
|
+
const remaining = {
|
|
405
|
+
...state,
|
|
406
|
+
workflows,
|
|
407
|
+
};
|
|
408
|
+
const stillReferenced = findDanglingRefs(remaining).filter((entry) => entry.startsWith(`"${args.name}"`));
|
|
409
|
+
const warning = stillReferenced.length > 0
|
|
410
|
+
? ` Warning: it is still referenced by subflow nodes (${stillReferenced.join('; ')}) — update those or re-add it before exiting.`
|
|
411
|
+
: '';
|
|
412
|
+
return {
|
|
413
|
+
result: `Workflow "${args.name}" removed.${warning}`,
|
|
414
|
+
state: remaining,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function executeGetWorkflow(args, state) {
|
|
418
|
+
const doc = state.workflows[args.name];
|
|
419
|
+
if (!doc) {
|
|
420
|
+
const existing = Object.keys(state.workflows).sort().join(', ') || '(none)';
|
|
421
|
+
return {
|
|
422
|
+
result: `No workflow named "${args.name}". Existing workflows: ${existing}.`,
|
|
423
|
+
state,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
result: JSON.stringify(doc, null, 2),
|
|
428
|
+
state,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function executeExitPlanMode(args, state, options) {
|
|
432
|
+
if (state.phase !== PlanPhase.Planning) {
|
|
433
|
+
return {
|
|
434
|
+
result: `Cannot exit plan mode: current phase is "${state.phase}".`,
|
|
435
|
+
state,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
if (args.action === 'cancel') {
|
|
439
|
+
return {
|
|
440
|
+
result: 'Plan cancelled. Returned to idle.',
|
|
441
|
+
state: createDefaultState(),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (!state.prd) {
|
|
445
|
+
return {
|
|
446
|
+
result: 'Cannot execute: no PRD has been written. Call plan/updatePrd first.',
|
|
447
|
+
state,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (!state.planTree) {
|
|
451
|
+
return {
|
|
452
|
+
result: 'Cannot execute: no plan tree has been set. Call plan/setPlanTree first.',
|
|
453
|
+
state,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// Structural validation runs before onExit so the user is never
|
|
457
|
+
// asked to approve a plan that cannot hydrate.
|
|
458
|
+
const dangling = findDanglingRefs(state);
|
|
459
|
+
if (dangling.length > 0) {
|
|
460
|
+
return {
|
|
461
|
+
result: `Cannot execute: subflow refs have no matching workflow: ${dangling.join('; ')}. Define them with plan/setWorkflow or remove the references.`,
|
|
462
|
+
state,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const cycle = findWorkflowCycle(state.workflows);
|
|
466
|
+
if (cycle) {
|
|
467
|
+
return {
|
|
468
|
+
result: `Cannot execute: workflows reference each other in a cycle: ${cycle.join(' -> ')}. Break the cycle before exiting.`,
|
|
469
|
+
state,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (options.onExit) {
|
|
473
|
+
const { approved } = await options.onExit(state);
|
|
474
|
+
if (!approved) {
|
|
475
|
+
return {
|
|
476
|
+
result: 'User did not approve the plan. Stay in plan mode, address their feedback, and call plan/exitPlanMode again when ready.',
|
|
477
|
+
state,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
result: 'Plan mode exited. Execution phase begun.',
|
|
483
|
+
state: {
|
|
484
|
+
...state,
|
|
485
|
+
phase: PlanPhase.Executing,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region Hooks
|
|
491
|
+
/** Renders the phase-appropriate recall payload, or null when the layer sits the turn out. */
|
|
492
|
+
function renderPlanRecall(state, budget, options) {
|
|
493
|
+
if (state.phase === PlanPhase.Idle) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const renderer = RECALL_RENDERERS[state.phase];
|
|
497
|
+
if (!renderer) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
// estimateTokens counts ~4 chars per token, so budget*4 chars is the
|
|
501
|
+
// ceiling the renderer must fit under.
|
|
502
|
+
const content = renderer(state, {
|
|
503
|
+
style: options.style,
|
|
504
|
+
allowedTools: [
|
|
505
|
+
...options.allowedTools,
|
|
506
|
+
].sort(),
|
|
507
|
+
allowedNodeKinds: options.limits.allowedNodeKinds,
|
|
508
|
+
subAgentTool: options.subAgentTool,
|
|
509
|
+
extra: options.additionalPlanInstructions,
|
|
510
|
+
maxChars: budget * 4,
|
|
511
|
+
});
|
|
512
|
+
// A budget too small for even the compact briefing buys nothing but a
|
|
513
|
+
// fragment of a rule, so the layer sits the turn out.
|
|
514
|
+
if (content === null) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
items: [
|
|
519
|
+
createMessage(content, 'developer'),
|
|
520
|
+
],
|
|
521
|
+
tokenCount: estimateTokens(content),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
function buildPlanHooks(options) {
|
|
525
|
+
return {
|
|
526
|
+
async init({ storage }) {
|
|
527
|
+
const saved = await storage.get('state');
|
|
528
|
+
return {
|
|
529
|
+
state: saved ? normalizeState(saved) : createDefaultState(),
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
async recall({ state, budget }) {
|
|
533
|
+
return renderPlanRecall(state, budget, options);
|
|
534
|
+
},
|
|
535
|
+
async beforeToolCall({ toolName, state }) {
|
|
536
|
+
if (state.phase !== PlanPhase.Planning) {
|
|
537
|
+
return {
|
|
538
|
+
decision: {
|
|
539
|
+
action: SteeringAction.Allow,
|
|
540
|
+
},
|
|
541
|
+
state,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (options.allowedTools.has(toolName)) {
|
|
545
|
+
return {
|
|
546
|
+
decision: {
|
|
547
|
+
action: SteeringAction.Allow,
|
|
548
|
+
},
|
|
549
|
+
state,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
decision: {
|
|
554
|
+
action: SteeringAction.Deny,
|
|
555
|
+
guidance: `Plan mode is active. "${toolName}" is not allowed during planning. Use read-only tools (Read, Grep, Find, Ls) to explore the codebase, then call plan/updatePrd to write your PRD.`,
|
|
556
|
+
},
|
|
557
|
+
state,
|
|
558
|
+
};
|
|
559
|
+
},
|
|
560
|
+
async onSpawn({ parentState }) {
|
|
561
|
+
return {
|
|
562
|
+
childState: structuredClone(parentState),
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
async onComplete({ state, outcome }) {
|
|
566
|
+
if (state.phase !== PlanPhase.Executing) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
state: {
|
|
571
|
+
...state,
|
|
572
|
+
phase: outcome === 'success' ? PlanPhase.Completed : PlanPhase.Failed,
|
|
573
|
+
executionLog: trimExecutionLog([
|
|
574
|
+
...state.executionLog,
|
|
575
|
+
{
|
|
576
|
+
timestamp: Date.now(),
|
|
577
|
+
version: state.version,
|
|
578
|
+
outcome,
|
|
579
|
+
},
|
|
580
|
+
]),
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region Public API
|
|
588
|
+
/**
|
|
589
|
+
* Creates a plan context layer that manages the PRD authoring and plan execution lifecycle.
|
|
590
|
+
*
|
|
591
|
+
* The plan tree is a JSON `WorkflowDocument`; plans additionally store named
|
|
592
|
+
* workflows referenced from the tree via `subflow` nodes, keeping the reviewed
|
|
593
|
+
* tree small. On approval, hosts execute the plan by feeding `planTree` and
|
|
594
|
+
* `workflows` to the JSON workflow runtime (`parseAndRunWorkflow`).
|
|
595
|
+
*
|
|
596
|
+
* @public
|
|
597
|
+
* @param config - Optional configuration for scope, allowed tools, and limits.
|
|
598
|
+
* @returns A `ContextLayer` providing plan mode, PRD storage, and execution tracking.
|
|
599
|
+
*/
|
|
600
|
+
export function plan(config) {
|
|
601
|
+
const scope = config?.scope ?? 'thread';
|
|
602
|
+
const options = resolvePlanOptions(config);
|
|
603
|
+
return {
|
|
604
|
+
id: 'plan',
|
|
605
|
+
name: 'Plan',
|
|
606
|
+
slot: PLAN_SLOT,
|
|
607
|
+
scope,
|
|
608
|
+
budget: {
|
|
609
|
+
min: 100,
|
|
610
|
+
max: 3e3,
|
|
611
|
+
},
|
|
612
|
+
provides: {
|
|
613
|
+
status: layerData({
|
|
614
|
+
read: (state) => ({
|
|
615
|
+
phase: state.phase,
|
|
616
|
+
hasPrd: Boolean(state.prd),
|
|
617
|
+
hasPlanTree: state.planTree !== null,
|
|
618
|
+
workflowNames: Object.keys(state.workflows).sort(),
|
|
619
|
+
version: state.version,
|
|
620
|
+
}),
|
|
621
|
+
}),
|
|
622
|
+
enterPlanMode: layerFunction({
|
|
623
|
+
description: 'Enter plan mode. The agent switches to read-only exploration and PRD authoring.',
|
|
624
|
+
input: z.object({
|
|
625
|
+
goal: z.string().optional(),
|
|
626
|
+
}),
|
|
627
|
+
output: z.string(),
|
|
628
|
+
execute: (args, state) => executeEnterPlanMode(args, state, options),
|
|
629
|
+
}),
|
|
630
|
+
updatePrd: layerFunction({
|
|
631
|
+
description: 'Update the PRD document with new markdown content.',
|
|
632
|
+
input: z.object({
|
|
633
|
+
content: z.string(),
|
|
634
|
+
}),
|
|
635
|
+
output: z.string(),
|
|
636
|
+
execute: (args, state) => executeUpdatePrd(args, state, options),
|
|
637
|
+
}),
|
|
638
|
+
setPlanTree: layerFunction({
|
|
639
|
+
description: 'Set the execution plan as a JSON workflow document: { "document": { "version": 1, "root": <WorkflowNode> } }. ' +
|
|
640
|
+
// Drawn from the same table as the briefing's kind list, so the two
|
|
641
|
+
// can never disagree about what this plan is allowed to use.
|
|
642
|
+
`WorkflowNode is a discriminated union on "kind" (${nodeKindList(options.limits.allowedNodeKinds)}); every node needs a unique "id". ` +
|
|
643
|
+
'Keep this tree SMALL — reference named workflows with { "kind": "subflow", "id": "...", "ref": "<workflow-name>" } and define them via plan/setWorkflow. ' +
|
|
644
|
+
`Schema: ${SCHEMA_URL}`,
|
|
645
|
+
// The document is validated with WorkflowDocumentSchema inside execute
|
|
646
|
+
// rather than in the tool-parameter schema: serializing the full
|
|
647
|
+
// recursive node union into tool params would balloon every planning
|
|
648
|
+
// turn, and in-execute validation returns readable result strings.
|
|
649
|
+
input: z.object({
|
|
650
|
+
document: z.unknown(),
|
|
651
|
+
}),
|
|
652
|
+
output: z.string(),
|
|
653
|
+
execute: (args, state) => executeSetPlanTree(args, state, options),
|
|
654
|
+
}),
|
|
655
|
+
setWorkflow: layerFunction({
|
|
656
|
+
description: 'Create or replace a NAMED workflow that the plan tree references via { "kind": "subflow", "ref": "<name>" }. ' +
|
|
657
|
+
'Name must be a slug (lowercase letters, digits, hyphens/underscores, max 64 chars). Setting an existing name replaces it. ' +
|
|
658
|
+
`The document is a JSON workflow: { "version": 1, "root": <WorkflowNode> } per ${SCHEMA_URL}`,
|
|
659
|
+
input: z.object({
|
|
660
|
+
name: z.string().min(1),
|
|
661
|
+
document: z.unknown(),
|
|
662
|
+
}),
|
|
663
|
+
output: z.string(),
|
|
664
|
+
execute: (args, state) => executeSetWorkflow(args, state, options),
|
|
665
|
+
}),
|
|
666
|
+
removeWorkflow: layerFunction({
|
|
667
|
+
description: 'Remove a named workflow from the plan.',
|
|
668
|
+
input: z.object({
|
|
669
|
+
name: z.string().min(1),
|
|
670
|
+
}),
|
|
671
|
+
output: z.string(),
|
|
672
|
+
execute: (args, state) => executeRemoveWorkflow(args, state),
|
|
673
|
+
}),
|
|
674
|
+
getWorkflow: layerFunction({
|
|
675
|
+
description: 'Return the JSON of a named workflow so it can be reviewed or revised.',
|
|
676
|
+
input: z.object({
|
|
677
|
+
name: z.string().min(1),
|
|
678
|
+
}),
|
|
679
|
+
output: z.string(),
|
|
680
|
+
execute: (args, state) => executeGetWorkflow(args, state),
|
|
681
|
+
}),
|
|
682
|
+
exitPlanMode: layerFunction({
|
|
683
|
+
description: 'Exit plan mode. Use action "execute" to begin executing the plan, or "cancel" to discard it.',
|
|
684
|
+
input: z.object({
|
|
685
|
+
action: z.enum([
|
|
686
|
+
'execute',
|
|
687
|
+
'cancel',
|
|
688
|
+
]),
|
|
689
|
+
}),
|
|
690
|
+
output: z.string(),
|
|
691
|
+
execute: (args, state) => executeExitPlanMode(args, state, options),
|
|
692
|
+
}),
|
|
693
|
+
},
|
|
694
|
+
hooks: buildPlanHooks(options),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|