@noetic-tools/context 1.0.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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +28 -0
  3. package/dist/context/budget.d.ts +39 -0
  4. package/dist/context/budget.js +183 -0
  5. package/dist/context/exec-context-factory.d.ts +24 -0
  6. package/dist/context/exec-context-factory.js +30 -0
  7. package/dist/context/function-call-utils.d.ts +6 -0
  8. package/dist/context/function-call-utils.js +27 -0
  9. package/dist/context/layer-api.d.ts +11 -0
  10. package/dist/context/layer-api.js +91 -0
  11. package/dist/context/layer-lifecycle.d.ts +239 -0
  12. package/dist/context/layer-lifecycle.js +800 -0
  13. package/dist/context/layer-provides.d.ts +32 -0
  14. package/dist/context/layer-provides.js +33 -0
  15. package/dist/context/layer-usage.d.ts +13 -0
  16. package/dist/context/layer-usage.js +54 -0
  17. package/dist/context/layers/durable-task-state.d.ts +107 -0
  18. package/dist/context/layers/durable-task-state.js +250 -0
  19. package/dist/context/layers/file-reference.d.ts +55 -0
  20. package/dist/context/layers/file-reference.js +729 -0
  21. package/dist/context/layers/history-window.d.ts +31 -0
  22. package/dist/context/layers/history-window.js +150 -0
  23. package/dist/context/layers/observational-context.d.ts +56 -0
  24. package/dist/context/layers/observational-context.js +152 -0
  25. package/dist/context/layers/plan-context.d.ts +73 -0
  26. package/dist/context/layers/plan-context.js +749 -0
  27. package/dist/context/layers/static-content.d.ts +27 -0
  28. package/dist/context/layers/static-content.js +46 -0
  29. package/dist/context/layers/steering.d.ts +50 -0
  30. package/dist/context/layers/steering.js +291 -0
  31. package/dist/context/layers/temporal-context.d.ts +91 -0
  32. package/dist/context/layers/temporal-context.js +327 -0
  33. package/dist/context/layers/tool-context-layer.d.ts +15 -0
  34. package/dist/context/layers/tool-context-layer.js +43 -0
  35. package/dist/context/layers/working-memory-context.d.ts +46 -0
  36. package/dist/context/layers/working-memory-context.js +155 -0
  37. package/dist/context/projector.d.ts +19 -0
  38. package/dist/context/projector.js +89 -0
  39. package/dist/context/scope.d.ts +3 -0
  40. package/dist/context/scope.js +42 -0
  41. package/dist/context/storage-batch.d.ts +18 -0
  42. package/dist/context/storage-batch.js +35 -0
  43. package/dist/context/strip-unresolved.d.ts +13 -0
  44. package/dist/context/strip-unresolved.js +35 -0
  45. package/dist/deprecated.d.ts +43 -0
  46. package/dist/deprecated.js +28 -0
  47. package/dist/index.d.ts +29 -0
  48. package/dist/index.js +31 -0
  49. package/package.json +63 -0
@@ -0,0 +1,749 @@
1
+ import { createMessage, estimateTokens, Slot, SteeringAction, WorkflowDocumentSchema, walkWorkflow, workflowDepth, } from '@noetic-tools/types';
2
+ import { z } from 'zod';
3
+ import { layerData, layerFn } from '../layer-provides.js';
4
+ //#region Constants
5
+ const MAX_PRD_LENGTH = 5e4;
6
+ const MAX_DEPTH = 5;
7
+ const MAX_WORKFLOWS = 20;
8
+ const MAX_WORKFLOW_CHARS = 2e4;
9
+ const MAX_EXECUTION_LOG_ENTRIES = 10;
10
+ const PLAN_SLOT = Slot.PROCEDURAL - 10; // 240
11
+ const SCHEMA_URL = 'https://noetic.tools/schema/noetic-workflow.schema.json';
12
+ /** Slug rule for named workflows: lowercase alphanumeric start, then `-`/`_` allowed, max 64 chars. */
13
+ const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
14
+ const ALLOWED_TOOLS_IN_PLAN_MODE = new Set([
15
+ 'Read',
16
+ 'Grep',
17
+ 'Find',
18
+ 'Ls',
19
+ 'AskUserQuestion',
20
+ 'activateSkill',
21
+ 'agent',
22
+ 'checkAgent',
23
+ 'sendMessage',
24
+ 'requestPlanApproval',
25
+ 'plan/enterPlanMode',
26
+ 'plan/updatePrd',
27
+ 'plan/setPlanTree',
28
+ 'plan/setWorkflow',
29
+ 'plan/removeWorkflow',
30
+ 'plan/getWorkflow',
31
+ 'plan/exitPlanMode',
32
+ ]);
33
+ //#endregion
34
+ //#region Types
35
+ export const PlanPhase = {
36
+ Idle: 'idle',
37
+ Planning: 'planning',
38
+ Executing: 'executing',
39
+ Completed: 'completed',
40
+ Failed: 'failed',
41
+ };
42
+ //#endregion
43
+ //#region Helpers
44
+ function createDefaultState() {
45
+ return {
46
+ phase: PlanPhase.Idle,
47
+ prd: null,
48
+ planTree: null,
49
+ workflows: {},
50
+ executionLog: [],
51
+ version: 0,
52
+ };
53
+ }
54
+ /**
55
+ * Persisted state may predate the WorkflowDocument plan format (legacy FlowNode
56
+ * trees) or lack the workflows map. An unparseable tree resets to null — the
57
+ * model simply re-authors it — rather than failing init.
58
+ */
59
+ function normalizeState(saved) {
60
+ const tree = saved.planTree !== null && WorkflowDocumentSchema.safeParse(saved.planTree).success
61
+ ? saved.planTree
62
+ : null;
63
+ const workflows = {};
64
+ for (const [name, doc] of Object.entries(saved.workflows ?? {})) {
65
+ if (WorkflowDocumentSchema.safeParse(doc).success) {
66
+ workflows[name] = doc;
67
+ }
68
+ }
69
+ return {
70
+ ...saved,
71
+ planTree: tree,
72
+ workflows,
73
+ };
74
+ }
75
+ /**
76
+ * LLM tool calls frequently stringify a nested-object argument. Accept the
77
+ * document as either a native object or a JSON string, then validate against
78
+ * `WorkflowDocumentSchema`, returning a readable error summary on failure.
79
+ */
80
+ function parseDocument(input) {
81
+ let candidate = input;
82
+ if (typeof candidate === 'string') {
83
+ try {
84
+ candidate = JSON.parse(candidate);
85
+ }
86
+ catch {
87
+ return {
88
+ ok: false,
89
+ error: 'the value is not valid JSON',
90
+ };
91
+ }
92
+ }
93
+ const parsed = WorkflowDocumentSchema.safeParse(candidate);
94
+ if (!parsed.success) {
95
+ const issues = parsed.error.issues
96
+ .slice(0, 3)
97
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
98
+ .join('; ');
99
+ return {
100
+ ok: false,
101
+ error: issues,
102
+ };
103
+ }
104
+ return {
105
+ ok: true,
106
+ doc: parsed.data,
107
+ };
108
+ }
109
+ /**
110
+ * Validates a parsed document against the layer's structural limits. Returns a
111
+ * readable rejection string, or null when the document passes.
112
+ */
113
+ function checkDocumentLimits(doc, limits) {
114
+ if (workflowDepth(doc.root) > limits.maxDepth) {
115
+ return `document exceeds maximum depth of ${limits.maxDepth}`;
116
+ }
117
+ if (limits.allowedNodeKinds) {
118
+ const allowed = new Set(limits.allowedNodeKinds);
119
+ const disallowed = new Set();
120
+ for (const node of walkWorkflow(doc.root)) {
121
+ if (!allowed.has(node.kind)) {
122
+ disallowed.add(node.kind);
123
+ }
124
+ }
125
+ if (disallowed.size > 0) {
126
+ return `document uses disallowed node kinds: ${[
127
+ ...disallowed,
128
+ ].join(', ')}. Allowed: ${limits.allowedNodeKinds.join(', ')}`;
129
+ }
130
+ }
131
+ const badRefs = collectSubflowRefs(doc).filter((ref) => !WORKFLOW_NAME_RE.test(ref));
132
+ if (badRefs.length > 0) {
133
+ return `subflow refs are not valid workflow names: ${badRefs.join(', ')}. Names are lowercase slugs (a-z, 0-9, -, _), max 64 chars`;
134
+ }
135
+ return null;
136
+ }
137
+ /** Collects every named subflow ref in a document (inline subflows contribute their nested refs via the walk). */
138
+ function collectSubflowRefs(doc) {
139
+ const refs = [];
140
+ for (const node of walkWorkflow(doc.root)) {
141
+ if (node.kind === 'subflow' && node.ref) {
142
+ refs.push(node.ref);
143
+ }
144
+ }
145
+ return refs;
146
+ }
147
+ /**
148
+ * Finds refs with no matching stored workflow, across the tree and every
149
+ * stored workflow. Returns readable `"name" (referenced from <where>)` items.
150
+ */
151
+ function findDanglingRefs(state) {
152
+ const dangling = [];
153
+ const defined = new Set(Object.keys(state.workflows));
154
+ const check = (doc, where) => {
155
+ for (const ref of collectSubflowRefs(doc)) {
156
+ if (!defined.has(ref)) {
157
+ dangling.push(`"${ref}" (referenced from ${where})`);
158
+ }
159
+ }
160
+ };
161
+ if (state.planTree) {
162
+ check(state.planTree, 'the plan tree');
163
+ }
164
+ for (const [name, doc] of Object.entries(state.workflows)) {
165
+ check(doc, `workflow "${name}"`);
166
+ }
167
+ return dangling;
168
+ }
169
+ /**
170
+ * Detects a reference cycle among named workflows (self-reference included).
171
+ * Returns the cycle path for the rejection message, or null when acyclic.
172
+ */
173
+ function findWorkflowCycle(workflows) {
174
+ const edges = new Map();
175
+ for (const [name, doc] of Object.entries(workflows)) {
176
+ edges.set(name, collectSubflowRefs(doc).filter((ref) => ref in workflows));
177
+ }
178
+ const visiting = new Set();
179
+ const done = new Set();
180
+ const path = [];
181
+ const visit = (name) => {
182
+ if (done.has(name)) {
183
+ return null;
184
+ }
185
+ if (visiting.has(name)) {
186
+ return [
187
+ ...path.slice(path.indexOf(name)),
188
+ name,
189
+ ];
190
+ }
191
+ visiting.add(name);
192
+ path.push(name);
193
+ for (const next of edges.get(name) ?? []) {
194
+ const cycle = visit(next);
195
+ if (cycle) {
196
+ return cycle;
197
+ }
198
+ }
199
+ path.pop();
200
+ visiting.delete(name);
201
+ done.add(name);
202
+ return null;
203
+ };
204
+ for (const name of edges.keys()) {
205
+ const cycle = visit(name);
206
+ if (cycle) {
207
+ return cycle;
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+ function buildAllowedTools(config) {
213
+ if (!config?.additionalAllowedTools?.length) {
214
+ return ALLOWED_TOOLS_IN_PLAN_MODE;
215
+ }
216
+ return new Set([
217
+ ...ALLOWED_TOOLS_IN_PLAN_MODE,
218
+ ...config.additionalAllowedTools,
219
+ ]);
220
+ }
221
+ function trimExecutionLog(log) {
222
+ if (log.length <= MAX_EXECUTION_LOG_ENTRIES) {
223
+ return log;
224
+ }
225
+ return log.slice(log.length - MAX_EXECUTION_LOG_ENTRIES);
226
+ }
227
+ /** One-line summary of a workflow: node count and a kind histogram. */
228
+ function summarizeWorkflow(name, doc) {
229
+ const counts = new Map();
230
+ let total = 0;
231
+ for (const node of walkWorkflow(doc.root)) {
232
+ total++;
233
+ counts.set(node.kind, (counts.get(node.kind) ?? 0) + 1);
234
+ }
235
+ const histogram = [
236
+ ...counts.entries(),
237
+ ]
238
+ .map(([kind, n]) => `${kind} x${n}`)
239
+ .join(', ');
240
+ return `- ${name}: ${total} node${total === 1 ? '' : 's'} (${histogram})`;
241
+ }
242
+ function renderWorkflowSummaries(state) {
243
+ const entries = Object.entries(state.workflows).sort(([a], [b]) => a.localeCompare(b));
244
+ if (entries.length === 0) {
245
+ return [];
246
+ }
247
+ return [
248
+ '',
249
+ '## Named Workflows',
250
+ '',
251
+ ...entries.map(([name, doc]) => summarizeWorkflow(name, doc)),
252
+ "(Use plan/getWorkflow to review a workflow's JSON before revising it.)",
253
+ ];
254
+ }
255
+ //#endregion
256
+ //#region Recall Renderers
257
+ function recallPlanning(state, additionalInstructions) {
258
+ const sections = [
259
+ '<plan_mode>',
260
+ 'You are in PLAN MODE. You may only use read-only tools (Read, Grep, Find, Ls), AskUserQuestion, skill activation, sub-agent coordination tools, and requestPlanApproval to explore the codebase and request execution approval.',
261
+ 'Your goal is to produce a PRD document and (optionally) a structured execution plan.',
262
+ '',
263
+ '## Workflow (5 phases)',
264
+ '',
265
+ '1. **Initial Understanding** — Read code and gather context. To parallelise exploration, use the `agent` tool to spawn bounded read-only sub-agents; up to 3 in parallel. Each sub-agent returns a focused report.',
266
+ '2. **Design** — Synthesise findings. Optionally spawn planning sub-agents (1–3 in parallel) to draft alternative implementation approaches and surface trade-offs.',
267
+ '3. **Review** — Read the critical files identified by your subagents directly so you understand them first-hand. If anything is ambiguous, ask the user a focused question.',
268
+ '4. **Final Plan** — Write the PRD via `plan/updatePrd`. Lead with a **Context** section (why this change), then your single recommended approach, the paths of files to modify, existing functions/utilities to reuse, and a **Verification** section. Then call `plan/setPlanTree` with `{ "document": { "version": 1, "root": <WorkflowNode> } }` — a JSON workflow conforming to ' +
269
+ SCHEMA_URL +
270
+ '. Node kinds: `llm` (model turn), `tool` (single tool call), `sequence`, `fork` (parallel paths), `branch` (substring routing), `loop` (body + until predicate), `spawn` (isolated child), `subflow` (reference to a named workflow), and coding-agent nodes (`claude-code`, `codex`, `opencode`, `pi`). Every node needs a unique `id`. **Keep the tree SMALL** — aim for at most ~7 top-level nodes the user can review at a glance. Factor detailed mechanics (multi-step verification, retries, fan-out) into NAMED workflows: define each with `plan/setWorkflow` `{ "name": "<slug>", "document": {...} }` and reference it from the tree with `{ "kind": "subflow", "id": "...", "ref": "<slug>" }`. Named workflows may reference other named workflows but must not form cycles.',
271
+ '5. **Exit** — Call `plan/exitPlanMode` with `{ action: "execute" }` to request approval. Every subflow ref must name a defined workflow or the exit is rejected. The user must accept before execution begins; if they reject, you stay in Plan Mode and may revise.',
272
+ '',
273
+ '## Available actions',
274
+ '- `plan/updatePrd` — set markdown PRD content',
275
+ '- `plan/setPlanTree` — set the reviewed plan as a JSON workflow document',
276
+ '- `plan/setWorkflow` — create or replace a named workflow (referenced via subflow nodes)',
277
+ '- `plan/removeWorkflow` — delete a named workflow',
278
+ "- `plan/getWorkflow` — read back a named workflow's JSON",
279
+ '- `plan/exitPlanMode` `{ action: "execute" }` — request approval and exit to executing',
280
+ '- `plan/exitPlanMode` `{ action: "cancel" }` — discard plan and return to idle',
281
+ '',
282
+ '## Constraints',
283
+ '- DO NOT create, modify, or delete files (other than via the plan/* actions).',
284
+ '- DO NOT run mutating shell commands. Read-only exploration only.',
285
+ '- End each turn either by asking the user a focused clarifying question or by calling `plan/exitPlanMode`.',
286
+ ];
287
+ if (additionalInstructions) {
288
+ sections.push('', '## Additional Instructions', '', additionalInstructions);
289
+ }
290
+ if (state.prd) {
291
+ sections.push('', '## Current PRD Draft', '', state.prd);
292
+ }
293
+ if (state.planTree) {
294
+ sections.push('', '## Current Plan Tree', '', JSON.stringify(state.planTree, null, 2));
295
+ }
296
+ sections.push(...renderWorkflowSummaries(state));
297
+ sections.push('</plan_mode>');
298
+ return sections.join('\n');
299
+ }
300
+ function recallExecuting(state) {
301
+ const sections = [
302
+ '<active_plan>',
303
+ '## PRD',
304
+ '',
305
+ state.prd ?? '',
306
+ ];
307
+ if (state.planTree) {
308
+ sections.push('', '## Execution Plan', '', JSON.stringify(state.planTree, null, 2));
309
+ }
310
+ sections.push(...renderWorkflowSummaries(state));
311
+ sections.push('</active_plan>');
312
+ return sections.join('\n');
313
+ }
314
+ function recallTerminal(state) {
315
+ const lastEntry = state.executionLog[state.executionLog.length - 1];
316
+ const outcome = lastEntry?.outcome ?? 'unknown';
317
+ return `<plan_outcome>Plan v${state.version} ${outcome}.</plan_outcome>`;
318
+ }
319
+ const RECALL_RENDERERS = {
320
+ [PlanPhase.Planning]: recallPlanning,
321
+ [PlanPhase.Executing]: recallExecuting,
322
+ [PlanPhase.Completed]: recallTerminal,
323
+ [PlanPhase.Failed]: recallTerminal,
324
+ };
325
+ //#endregion
326
+ //#region Public API
327
+ /**
328
+ * Creates a plan context layer that manages the PRD authoring and plan execution lifecycle.
329
+ *
330
+ * The plan tree is a JSON `WorkflowDocument`; plans additionally store named
331
+ * workflows referenced from the tree via `subflow` nodes, keeping the reviewed
332
+ * tree small. On approval, hosts execute the plan by feeding `planTree` and
333
+ * `workflows` to the JSON workflow runtime (`parseAndRunWorkflow`).
334
+ *
335
+ * @public
336
+ * @param config - Optional configuration for scope, allowed tools, and limits.
337
+ * @returns A `ContextLayer` providing plan mode, PRD storage, and execution tracking.
338
+ */
339
+ export function planContext(config) {
340
+ const scope = config?.scope ?? 'thread';
341
+ const maxPrdLength = config?.maxPrdLength ?? MAX_PRD_LENGTH;
342
+ const maxWorkflows = config?.maxWorkflows ?? MAX_WORKFLOWS;
343
+ const maxWorkflowChars = config?.maxWorkflowChars ?? MAX_WORKFLOW_CHARS;
344
+ const limits = {
345
+ maxDepth: config?.maxDepth ?? MAX_DEPTH,
346
+ allowedNodeKinds: config?.allowedNodeKinds,
347
+ };
348
+ const allowedTools = buildAllowedTools(config);
349
+ const additionalPlanInstructions = config?.additionalPlanInstructions;
350
+ const onEnterSession = config?.onEnterSession;
351
+ const onExit = config?.onExit;
352
+ return {
353
+ id: 'plan',
354
+ name: 'Plan Memory',
355
+ slot: PLAN_SLOT,
356
+ scope,
357
+ budget: {
358
+ min: 100,
359
+ max: 3e3,
360
+ },
361
+ provides: {
362
+ status: layerData({
363
+ read: (state) => ({
364
+ phase: state.phase,
365
+ hasPrd: Boolean(state.prd),
366
+ hasPlanTree: state.planTree !== null,
367
+ workflowNames: Object.keys(state.workflows).sort(),
368
+ version: state.version,
369
+ }),
370
+ }),
371
+ enterPlanMode: layerFn({
372
+ description: 'Enter plan mode. The agent switches to read-only exploration and PRD authoring.',
373
+ input: z.object({
374
+ goal: z.string().optional(),
375
+ }),
376
+ output: z.string(),
377
+ execute: async (args, state) => {
378
+ if (state.phase === PlanPhase.Planning || state.phase === PlanPhase.Executing) {
379
+ return {
380
+ result: `Cannot enter plan mode: a plan is already active (phase "${state.phase}").`,
381
+ state,
382
+ };
383
+ }
384
+ const session = onEnterSession ? await onEnterSession() : null;
385
+ return {
386
+ result: 'Plan mode activated. Explore the codebase, then call plan/updatePrd.',
387
+ state: {
388
+ ...state,
389
+ phase: PlanPhase.Planning,
390
+ prd: args.goal ? `# Goal\n\n${args.goal}\n` : null,
391
+ planTree: null,
392
+ workflows: {},
393
+ executionLog: [],
394
+ version: state.version + 1,
395
+ planSlug: session?.slug ?? null,
396
+ },
397
+ };
398
+ },
399
+ }),
400
+ updatePrd: layerFn({
401
+ description: 'Update the PRD document with new markdown content.',
402
+ input: z.object({
403
+ content: z.string(),
404
+ }),
405
+ output: z.string(),
406
+ execute: async (args, state) => {
407
+ if (state.phase !== PlanPhase.Planning) {
408
+ return {
409
+ result: `Cannot update PRD: current phase is "${state.phase}". Enter plan mode first.`,
410
+ state,
411
+ };
412
+ }
413
+ if (args.content.length > maxPrdLength) {
414
+ return {
415
+ result: `PRD content exceeds maximum length of ${maxPrdLength} characters.`,
416
+ state,
417
+ };
418
+ }
419
+ return {
420
+ result: 'PRD updated successfully.',
421
+ state: {
422
+ ...state,
423
+ prd: args.content,
424
+ },
425
+ };
426
+ },
427
+ }),
428
+ setPlanTree: layerFn({
429
+ description: 'Set the execution plan as a JSON workflow document: { "document": { "version": 1, "root": <WorkflowNode> } }. ' +
430
+ 'WorkflowNode is a discriminated union on "kind" (llm, tool, sequence, fork, branch, loop, spawn, subflow, claude-code, codex, opencode, pi); every node needs a unique "id". ' +
431
+ 'Keep this tree SMALL — reference named workflows with { "kind": "subflow", "id": "...", "ref": "<workflow-name>" } and define them via plan/setWorkflow. ' +
432
+ `Schema: ${SCHEMA_URL}`,
433
+ // The document is validated with WorkflowDocumentSchema inside execute
434
+ // rather than in the tool-parameter schema: serializing the full
435
+ // recursive node union into tool params would balloon every planning
436
+ // turn, and in-execute validation returns readable result strings.
437
+ input: z.object({
438
+ document: z.unknown(),
439
+ }),
440
+ output: z.string(),
441
+ execute: async (args, state) => {
442
+ if (state.phase !== PlanPhase.Planning) {
443
+ return {
444
+ result: `Cannot set plan tree: current phase is "${state.phase}". Enter plan mode first.`,
445
+ state,
446
+ };
447
+ }
448
+ const parsed = parseDocument(args.document);
449
+ if (!parsed.ok) {
450
+ return {
451
+ result: `Cannot set plan tree: ${parsed.error}. See ${SCHEMA_URL}.`,
452
+ state,
453
+ };
454
+ }
455
+ const limitError = checkDocumentLimits(parsed.doc, limits);
456
+ if (limitError) {
457
+ return {
458
+ result: `Cannot set plan tree: ${limitError}.`,
459
+ state,
460
+ };
461
+ }
462
+ const defined = new Set(Object.keys(state.workflows));
463
+ const dangling = [
464
+ ...new Set(collectSubflowRefs(parsed.doc).filter((ref) => !defined.has(ref))),
465
+ ];
466
+ const result = dangling.length > 0
467
+ ? `Plan tree set. Subflow refs not yet defined: ${dangling.join(', ')} — define each with plan/setWorkflow, then call plan/exitPlanMode.`
468
+ : 'Plan tree set successfully. Call plan/exitPlanMode to request approval.';
469
+ return {
470
+ result,
471
+ state: {
472
+ ...state,
473
+ planTree: parsed.doc,
474
+ },
475
+ };
476
+ },
477
+ }),
478
+ setWorkflow: layerFn({
479
+ description: 'Create or replace a NAMED workflow that the plan tree references via { "kind": "subflow", "ref": "<name>" }. ' +
480
+ 'Name must be a slug (lowercase letters, digits, hyphens/underscores, max 64 chars). Setting an existing name replaces it. ' +
481
+ `The document is a JSON workflow: { "version": 1, "root": <WorkflowNode> } per ${SCHEMA_URL}`,
482
+ input: z.object({
483
+ name: z.string().min(1),
484
+ document: z.unknown(),
485
+ }),
486
+ output: z.string(),
487
+ execute: async (args, state) => {
488
+ if (state.phase !== PlanPhase.Planning) {
489
+ return {
490
+ result: `Cannot set workflow: current phase is "${state.phase}". Enter plan mode first.`,
491
+ state,
492
+ };
493
+ }
494
+ if (!WORKFLOW_NAME_RE.test(args.name)) {
495
+ return {
496
+ 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".`,
497
+ state,
498
+ };
499
+ }
500
+ const replacing = args.name in state.workflows;
501
+ if (!replacing && Object.keys(state.workflows).length >= maxWorkflows) {
502
+ return {
503
+ result: `Cannot set workflow: the plan already has ${maxWorkflows} workflows. Remove one with plan/removeWorkflow or replace an existing name.`,
504
+ state,
505
+ };
506
+ }
507
+ const parsed = parseDocument(args.document);
508
+ if (!parsed.ok) {
509
+ return {
510
+ result: `Cannot set workflow "${args.name}": ${parsed.error}. See ${SCHEMA_URL}.`,
511
+ state,
512
+ };
513
+ }
514
+ const serializedLength = JSON.stringify(parsed.doc).length;
515
+ if (serializedLength > maxWorkflowChars) {
516
+ return {
517
+ result: `Cannot set workflow "${args.name}": document is ${serializedLength} chars, over the ${maxWorkflowChars} limit. Split it into smaller named workflows.`,
518
+ state,
519
+ };
520
+ }
521
+ const limitError = checkDocumentLimits(parsed.doc, limits);
522
+ if (limitError) {
523
+ return {
524
+ result: `Cannot set workflow "${args.name}": ${limitError}.`,
525
+ state,
526
+ };
527
+ }
528
+ const workflows = {
529
+ ...state.workflows,
530
+ [args.name]: parsed.doc,
531
+ };
532
+ const names = Object.keys(workflows).sort().join(', ');
533
+ return {
534
+ 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.`,
535
+ state: {
536
+ ...state,
537
+ workflows,
538
+ },
539
+ };
540
+ },
541
+ }),
542
+ removeWorkflow: layerFn({
543
+ description: 'Remove a named workflow from the plan.',
544
+ input: z.object({
545
+ name: z.string().min(1),
546
+ }),
547
+ output: z.string(),
548
+ execute: async (args, state) => {
549
+ if (state.phase !== PlanPhase.Planning) {
550
+ return {
551
+ result: `Cannot remove workflow: current phase is "${state.phase}". Enter plan mode first.`,
552
+ state,
553
+ };
554
+ }
555
+ if (!(args.name in state.workflows)) {
556
+ const existing = Object.keys(state.workflows).sort().join(', ') || '(none)';
557
+ return {
558
+ result: `No workflow named "${args.name}". Existing workflows: ${existing}.`,
559
+ state,
560
+ };
561
+ }
562
+ const workflows = {
563
+ ...state.workflows,
564
+ };
565
+ delete workflows[args.name];
566
+ const remaining = {
567
+ ...state,
568
+ workflows,
569
+ };
570
+ const stillReferenced = findDanglingRefs(remaining).filter((entry) => entry.startsWith(`"${args.name}"`));
571
+ const warning = stillReferenced.length > 0
572
+ ? ` Warning: it is still referenced by subflow nodes (${stillReferenced.join('; ')}) — update those or re-add it before exiting.`
573
+ : '';
574
+ return {
575
+ result: `Workflow "${args.name}" removed.${warning}`,
576
+ state: remaining,
577
+ };
578
+ },
579
+ }),
580
+ getWorkflow: layerFn({
581
+ description: 'Return the JSON of a named workflow so it can be reviewed or revised.',
582
+ input: z.object({
583
+ name: z.string().min(1),
584
+ }),
585
+ output: z.string(),
586
+ execute: async (args, state) => {
587
+ const doc = state.workflows[args.name];
588
+ if (!doc) {
589
+ const existing = Object.keys(state.workflows).sort().join(', ') || '(none)';
590
+ return {
591
+ result: `No workflow named "${args.name}". Existing workflows: ${existing}.`,
592
+ state,
593
+ };
594
+ }
595
+ return {
596
+ result: JSON.stringify(doc, null, 2),
597
+ state,
598
+ };
599
+ },
600
+ }),
601
+ exitPlanMode: layerFn({
602
+ description: 'Exit plan mode. Use action "execute" to begin executing the plan, or "cancel" to discard it.',
603
+ input: z.object({
604
+ action: z.enum([
605
+ 'execute',
606
+ 'cancel',
607
+ ]),
608
+ }),
609
+ output: z.string(),
610
+ execute: async (args, state) => {
611
+ if (state.phase !== PlanPhase.Planning) {
612
+ return {
613
+ result: `Cannot exit plan mode: current phase is "${state.phase}".`,
614
+ state,
615
+ };
616
+ }
617
+ if (args.action === 'cancel') {
618
+ return {
619
+ result: 'Plan cancelled. Returned to idle.',
620
+ state: createDefaultState(),
621
+ };
622
+ }
623
+ if (!state.prd) {
624
+ return {
625
+ result: 'Cannot execute: no PRD has been written. Call plan/updatePrd first.',
626
+ state,
627
+ };
628
+ }
629
+ if (!state.planTree) {
630
+ return {
631
+ result: 'Cannot execute: no plan tree has been set. Call plan/setPlanTree first.',
632
+ state,
633
+ };
634
+ }
635
+ // Structural validation runs before onExit so the user is never
636
+ // asked to approve a plan that cannot hydrate.
637
+ const dangling = findDanglingRefs(state);
638
+ if (dangling.length > 0) {
639
+ return {
640
+ result: `Cannot execute: subflow refs have no matching workflow: ${dangling.join('; ')}. Define them with plan/setWorkflow or remove the references.`,
641
+ state,
642
+ };
643
+ }
644
+ const cycle = findWorkflowCycle(state.workflows);
645
+ if (cycle) {
646
+ return {
647
+ result: `Cannot execute: workflows reference each other in a cycle: ${cycle.join(' -> ')}. Break the cycle before exiting.`,
648
+ state,
649
+ };
650
+ }
651
+ if (onExit) {
652
+ const { approved } = await onExit(state);
653
+ if (!approved) {
654
+ return {
655
+ result: 'User did not approve the plan. Stay in plan mode, address their feedback, and call plan/exitPlanMode again when ready.',
656
+ state,
657
+ };
658
+ }
659
+ }
660
+ return {
661
+ result: 'Plan mode exited. Execution phase begun.',
662
+ state: {
663
+ ...state,
664
+ phase: PlanPhase.Executing,
665
+ },
666
+ };
667
+ },
668
+ }),
669
+ },
670
+ hooks: {
671
+ async init({ storage }) {
672
+ const saved = await storage.get('state');
673
+ return {
674
+ state: saved ? normalizeState(saved) : createDefaultState(),
675
+ };
676
+ },
677
+ async recall({ state, budget }) {
678
+ if (state.phase === PlanPhase.Idle) {
679
+ return null;
680
+ }
681
+ const renderer = RECALL_RENDERERS[state.phase];
682
+ if (!renderer) {
683
+ return null;
684
+ }
685
+ const content = renderer(state, additionalPlanInstructions);
686
+ // Respect the budget: estimateTokens uses ~4 chars/token, so cap the
687
+ // rendered text at budget*4 chars to keep tokenCount <= budget.
688
+ const maxChars = budget * 4;
689
+ const trimmed = content.length > maxChars ? content.slice(0, maxChars) : content;
690
+ return {
691
+ items: [
692
+ createMessage(trimmed, 'developer'),
693
+ ],
694
+ tokenCount: estimateTokens(trimmed),
695
+ };
696
+ },
697
+ async beforeToolCall({ toolName, state }) {
698
+ if (state.phase !== PlanPhase.Planning) {
699
+ return {
700
+ decision: {
701
+ action: SteeringAction.Allow,
702
+ },
703
+ state,
704
+ };
705
+ }
706
+ if (allowedTools.has(toolName)) {
707
+ return {
708
+ decision: {
709
+ action: SteeringAction.Allow,
710
+ },
711
+ state,
712
+ };
713
+ }
714
+ return {
715
+ decision: {
716
+ action: SteeringAction.Deny,
717
+ 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.`,
718
+ },
719
+ state,
720
+ };
721
+ },
722
+ async onSpawn({ parentState }) {
723
+ return {
724
+ childState: structuredClone(parentState),
725
+ };
726
+ },
727
+ async onComplete({ state, outcome }) {
728
+ if (state.phase !== PlanPhase.Executing) {
729
+ return;
730
+ }
731
+ return {
732
+ state: {
733
+ ...state,
734
+ phase: outcome === 'success' ? PlanPhase.Completed : PlanPhase.Failed,
735
+ executionLog: trimExecutionLog([
736
+ ...state.executionLog,
737
+ {
738
+ timestamp: Date.now(),
739
+ version: state.version,
740
+ outcome,
741
+ },
742
+ ]),
743
+ },
744
+ };
745
+ },
746
+ },
747
+ };
748
+ }
749
+ //#endregion