@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.
Files changed (43) hide show
  1. package/README.md +2 -3
  2. package/dist/context/cache-anchoring.d.ts +103 -0
  3. package/dist/context/cache-anchoring.js +273 -0
  4. package/dist/context/layer-lifecycle.d.ts +3 -5
  5. package/dist/context/layer-lifecycle.js +12 -2
  6. package/dist/context/layer-provides.d.ts +1 -1
  7. package/dist/context/layer-provides.js +1 -1
  8. package/dist/context/layer-usage.d.ts +18 -3
  9. package/dist/context/layer-usage.js +10 -1
  10. package/dist/context/layers/{file-reference.d.ts → filesystem.d.ts} +1 -1
  11. package/dist/context/layers/{file-reference.js → filesystem.js} +60 -3
  12. package/dist/context/layers/{history-window.d.ts → history.d.ts} +4 -4
  13. package/dist/context/layers/{history-window.js → history.js} +6 -6
  14. package/dist/context/layers/{static-content.d.ts → instructions.d.ts} +3 -2
  15. package/dist/context/layers/{static-content.js → instructions.js} +5 -2
  16. package/dist/context/layers/{observational-context.d.ts → observations.d.ts} +12 -12
  17. package/dist/context/layers/{observational-context.js → observations.js} +7 -7
  18. package/dist/context/layers/plan-prompts.d.ts +1 -1
  19. package/dist/context/layers/plan-prompts.js +16 -16
  20. package/dist/context/layers/plan-state.d.ts +31 -0
  21. package/dist/context/layers/plan-state.js +12 -0
  22. package/dist/context/layers/{plan-context.d.ts → plan.d.ts} +6 -28
  23. package/dist/context/layers/plan.js +697 -0
  24. package/dist/context/layers/{working-memory-context.d.ts → scratchpad.d.ts} +13 -13
  25. package/dist/context/layers/{working-memory-context.js → scratchpad.js} +8 -8
  26. package/dist/context/layers/steering.d.ts +1 -0
  27. package/dist/context/layers/steering.js +6 -1
  28. package/dist/context/layers/{durable-task-state.d.ts → task-state.d.ts} +20 -20
  29. package/dist/context/layers/{durable-task-state.js → task-state.js} +9 -9
  30. package/dist/context/layers/{temporal-context.d.ts → temporal.d.ts} +5 -5
  31. package/dist/context/layers/{temporal-context.js → temporal.js} +11 -5
  32. package/dist/context/layers/{tool-context-layer.d.ts → tool-calls.d.ts} +2 -2
  33. package/dist/context/layers/{tool-context-layer.js → tool-calls.js} +1 -1
  34. package/dist/context/projector.d.ts +27 -8
  35. package/dist/context/projector.js +42 -13
  36. package/dist/context/strip-unresolved.d.ts +1 -1
  37. package/dist/context/strip-unresolved.js +1 -1
  38. package/dist/index.d.ts +10 -10
  39. package/dist/index.js +10 -10
  40. package/package.json +2 -2
  41. package/dist/context/layers/plan-context.js +0 -683
  42. package/dist/deprecated.d.ts +0 -43
  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