footprintjs 4.16.0 → 4.17.2

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 (188) hide show
  1. package/AGENTS.md +567 -82
  2. package/CLAUDE.md +57 -0
  3. package/README.md +2 -0
  4. package/dist/advanced.js +4 -2
  5. package/dist/detach.js +78 -0
  6. package/dist/esm/advanced.js +2 -1
  7. package/dist/esm/detach.js +57 -0
  8. package/dist/esm/lib/builder/FlowChartBuilder.js +171 -61
  9. package/dist/esm/lib/contract/openapi.js +4 -5
  10. package/dist/esm/lib/contract/schema.js +115 -4
  11. package/dist/esm/lib/decide/decide.js +4 -5
  12. package/dist/esm/lib/decide/evidence.js +3 -2
  13. package/dist/esm/lib/detach/drivers/immediate.js +66 -0
  14. package/dist/esm/lib/detach/drivers/microtaskBatch.js +113 -0
  15. package/dist/esm/lib/detach/drivers/sendBeacon.js +78 -0
  16. package/dist/esm/lib/detach/drivers/setImmediate.js +81 -0
  17. package/dist/esm/lib/detach/drivers/setTimeout.js +69 -0
  18. package/dist/esm/lib/detach/drivers/workerThread.js +117 -0
  19. package/dist/esm/lib/detach/flush.js +91 -0
  20. package/dist/esm/lib/detach/handle.js +134 -0
  21. package/dist/esm/lib/detach/registry.js +97 -0
  22. package/dist/esm/lib/detach/runChild.js +40 -0
  23. package/dist/esm/lib/detach/spawn.js +86 -0
  24. package/dist/esm/lib/detach/types.js +37 -0
  25. package/dist/esm/lib/engine/errors/errorInfo.js +4 -4
  26. package/dist/esm/lib/engine/graph/StageNode.js +2 -2
  27. package/dist/esm/lib/engine/handlers/ChildrenExecutor.js +9 -8
  28. package/dist/esm/lib/engine/handlers/ContinuationResolver.js +12 -9
  29. package/dist/esm/lib/engine/handlers/DeciderHandler.js +7 -8
  30. package/dist/esm/lib/engine/handlers/ExtractorRunner.js +14 -8
  31. package/dist/esm/lib/engine/handlers/NodeResolver.js +5 -3
  32. package/dist/esm/lib/engine/handlers/RuntimeStructureManager.js +11 -14
  33. package/dist/esm/lib/engine/handlers/SelectorHandler.js +9 -9
  34. package/dist/esm/lib/engine/handlers/StageRunner.js +9 -11
  35. package/dist/esm/lib/engine/handlers/SubflowExecutor.js +13 -12
  36. package/dist/esm/lib/engine/handlers/SubflowInputMapper.js +4 -4
  37. package/dist/esm/lib/engine/narrative/CombinedNarrativeRecorder.js +85 -96
  38. package/dist/esm/lib/engine/narrative/FlowRecorderDispatcher.js +18 -36
  39. package/dist/esm/lib/engine/narrative/NarrativeFlowRecorder.js +6 -5
  40. package/dist/esm/lib/engine/narrative/recorders/AdaptiveNarrativeFlowRecorder.js +7 -6
  41. package/dist/esm/lib/engine/narrative/recorders/ManifestFlowRecorder.js +10 -10
  42. package/dist/esm/lib/engine/narrative/recorders/MilestoneNarrativeFlowRecorder.js +5 -3
  43. package/dist/esm/lib/engine/narrative/recorders/ProgressiveNarrativeFlowRecorder.js +4 -3
  44. package/dist/esm/lib/engine/narrative/recorders/RLENarrativeFlowRecorder.js +4 -4
  45. package/dist/esm/lib/engine/narrative/recorders/SeparateNarrativeFlowRecorder.js +5 -6
  46. package/dist/esm/lib/engine/narrative/recorders/SilentNarrativeFlowRecorder.js +5 -6
  47. package/dist/esm/lib/engine/narrative/recorders/WindowedNarrativeFlowRecorder.js +5 -3
  48. package/dist/esm/lib/engine/traversal/FlowchartTraverser.js +97 -71
  49. package/dist/esm/lib/memory/DiagnosticCollector.js +6 -8
  50. package/dist/esm/lib/memory/EventLog.js +5 -3
  51. package/dist/esm/lib/memory/SharedMemory.js +3 -2
  52. package/dist/esm/lib/memory/StageContext.js +44 -14
  53. package/dist/esm/lib/memory/TransactionBuffer.js +9 -8
  54. package/dist/esm/lib/memory/backtrack.js +3 -4
  55. package/dist/esm/lib/memory/commitLogUtils.js +2 -2
  56. package/dist/esm/lib/memory/utils.js +2 -3
  57. package/dist/esm/lib/pause/types.js +33 -14
  58. package/dist/esm/lib/reactive/createTypedScope.js +10 -8
  59. package/dist/esm/lib/reactive/types.js +3 -1
  60. package/dist/esm/lib/recorder/BoundaryStateTracker.js +263 -0
  61. package/dist/esm/lib/recorder/CompositeRecorder.js +3 -1
  62. package/dist/esm/lib/recorder/InOutRecorder.js +5 -6
  63. package/dist/esm/lib/recorder/KeyedRecorder.js +2 -4
  64. package/dist/esm/lib/recorder/QualityRecorder.js +15 -14
  65. package/dist/esm/lib/recorder/SequenceRecorder.js +11 -12
  66. package/dist/esm/lib/recorder/TopologyRecorder.js +36 -40
  67. package/dist/esm/lib/recorder/index.js +2 -1
  68. package/dist/esm/lib/recorder/qualityTrace.js +4 -5
  69. package/dist/esm/lib/runner/ExecutionRuntime.js +20 -4
  70. package/dist/esm/lib/runner/FlowChartExecutor.js +99 -55
  71. package/dist/esm/lib/runner/RunContext.js +5 -3
  72. package/dist/esm/lib/runner/RunnableChart.js +7 -9
  73. package/dist/esm/lib/runner/getSubtreeSnapshot.js +4 -5
  74. package/dist/esm/lib/schema/errors.js +4 -3
  75. package/dist/esm/lib/schema/validate.js +4 -5
  76. package/dist/esm/lib/scope/ScopeFacade.js +52 -35
  77. package/dist/esm/lib/scope/providers/baseStateCompatible.js +9 -9
  78. package/dist/esm/lib/scope/providers/guards.js +2 -2
  79. package/dist/esm/lib/scope/recorders/DebugRecorder.js +9 -7
  80. package/dist/esm/lib/scope/recorders/MetricRecorder.js +10 -8
  81. package/dist/esm/lib/scope/state/zod/defineScopeFromZod.js +2 -3
  82. package/dist/esm/lib/scope/state/zod/resolver.js +2 -3
  83. package/dist/esm/lib/scope/state/zod/scopeFactory.js +16 -20
  84. package/dist/esm/lib/scope/state/zod/utils/validateHelper.js +57 -14
  85. package/dist/esm/trace.js +4 -1
  86. package/dist/lib/builder/FlowChartBuilder.js +171 -61
  87. package/dist/lib/contract/openapi.js +4 -5
  88. package/dist/lib/contract/schema.js +115 -4
  89. package/dist/lib/decide/decide.js +4 -5
  90. package/dist/lib/decide/evidence.js +3 -2
  91. package/dist/lib/detach/drivers/immediate.js +70 -0
  92. package/dist/lib/detach/drivers/microtaskBatch.js +117 -0
  93. package/dist/lib/detach/drivers/sendBeacon.js +82 -0
  94. package/dist/lib/detach/drivers/setImmediate.js +85 -0
  95. package/dist/lib/detach/drivers/setTimeout.js +73 -0
  96. package/dist/lib/detach/drivers/workerThread.js +121 -0
  97. package/dist/lib/detach/flush.js +95 -0
  98. package/dist/lib/detach/handle.js +140 -0
  99. package/dist/lib/detach/registry.js +106 -0
  100. package/dist/lib/detach/runChild.js +67 -0
  101. package/dist/lib/detach/spawn.js +92 -0
  102. package/dist/lib/detach/types.js +38 -0
  103. package/dist/lib/engine/errors/errorInfo.js +4 -4
  104. package/dist/lib/engine/graph/StageNode.js +2 -2
  105. package/dist/lib/engine/handlers/ChildrenExecutor.js +9 -8
  106. package/dist/lib/engine/handlers/ContinuationResolver.js +12 -9
  107. package/dist/lib/engine/handlers/DeciderHandler.js +7 -8
  108. package/dist/lib/engine/handlers/ExtractorRunner.js +14 -8
  109. package/dist/lib/engine/handlers/NodeResolver.js +5 -3
  110. package/dist/lib/engine/handlers/RuntimeStructureManager.js +11 -14
  111. package/dist/lib/engine/handlers/SelectorHandler.js +9 -9
  112. package/dist/lib/engine/handlers/StageRunner.js +9 -11
  113. package/dist/lib/engine/handlers/SubflowExecutor.js +13 -12
  114. package/dist/lib/engine/handlers/SubflowInputMapper.js +4 -4
  115. package/dist/lib/engine/narrative/CombinedNarrativeRecorder.js +85 -96
  116. package/dist/lib/engine/narrative/FlowRecorderDispatcher.js +18 -36
  117. package/dist/lib/engine/narrative/NarrativeFlowRecorder.js +6 -5
  118. package/dist/lib/engine/narrative/recorders/AdaptiveNarrativeFlowRecorder.js +7 -6
  119. package/dist/lib/engine/narrative/recorders/ManifestFlowRecorder.js +10 -10
  120. package/dist/lib/engine/narrative/recorders/MilestoneNarrativeFlowRecorder.js +5 -3
  121. package/dist/lib/engine/narrative/recorders/ProgressiveNarrativeFlowRecorder.js +4 -3
  122. package/dist/lib/engine/narrative/recorders/RLENarrativeFlowRecorder.js +4 -4
  123. package/dist/lib/engine/narrative/recorders/SeparateNarrativeFlowRecorder.js +5 -6
  124. package/dist/lib/engine/narrative/recorders/SilentNarrativeFlowRecorder.js +5 -6
  125. package/dist/lib/engine/narrative/recorders/WindowedNarrativeFlowRecorder.js +5 -3
  126. package/dist/lib/engine/traversal/FlowchartTraverser.js +97 -71
  127. package/dist/lib/memory/DiagnosticCollector.js +6 -8
  128. package/dist/lib/memory/EventLog.js +5 -3
  129. package/dist/lib/memory/SharedMemory.js +3 -2
  130. package/dist/lib/memory/StageContext.js +44 -14
  131. package/dist/lib/memory/TransactionBuffer.js +9 -8
  132. package/dist/lib/memory/backtrack.js +3 -4
  133. package/dist/lib/memory/commitLogUtils.js +2 -2
  134. package/dist/lib/memory/utils.js +2 -3
  135. package/dist/lib/pause/types.js +33 -14
  136. package/dist/lib/reactive/createTypedScope.js +10 -8
  137. package/dist/lib/reactive/types.js +3 -1
  138. package/dist/lib/recorder/BoundaryStateTracker.js +267 -0
  139. package/dist/lib/recorder/CompositeRecorder.js +3 -1
  140. package/dist/lib/recorder/InOutRecorder.js +5 -6
  141. package/dist/lib/recorder/KeyedRecorder.js +2 -4
  142. package/dist/lib/recorder/QualityRecorder.js +15 -14
  143. package/dist/lib/recorder/SequenceRecorder.js +11 -12
  144. package/dist/lib/recorder/TopologyRecorder.js +36 -40
  145. package/dist/lib/recorder/index.js +4 -2
  146. package/dist/lib/recorder/qualityTrace.js +4 -5
  147. package/dist/lib/runner/ExecutionRuntime.js +20 -4
  148. package/dist/lib/runner/FlowChartExecutor.js +99 -55
  149. package/dist/lib/runner/RunContext.js +5 -3
  150. package/dist/lib/runner/RunnableChart.js +7 -9
  151. package/dist/lib/runner/getSubtreeSnapshot.js +4 -5
  152. package/dist/lib/schema/errors.js +4 -3
  153. package/dist/lib/schema/validate.js +4 -5
  154. package/dist/lib/scope/ScopeFacade.js +52 -35
  155. package/dist/lib/scope/providers/baseStateCompatible.js +9 -9
  156. package/dist/lib/scope/providers/guards.js +2 -2
  157. package/dist/lib/scope/recorders/DebugRecorder.js +9 -7
  158. package/dist/lib/scope/recorders/MetricRecorder.js +10 -8
  159. package/dist/lib/scope/state/zod/defineScopeFromZod.js +2 -3
  160. package/dist/lib/scope/state/zod/resolver.js +2 -3
  161. package/dist/lib/scope/state/zod/scopeFactory.js +16 -20
  162. package/dist/lib/scope/state/zod/utils/validateHelper.js +57 -14
  163. package/dist/trace.js +6 -2
  164. package/dist/types/advanced.d.ts +1 -0
  165. package/dist/types/detach.d.ts +59 -0
  166. package/dist/types/lib/builder/FlowChartBuilder.d.ts +81 -0
  167. package/dist/types/lib/detach/drivers/immediate.d.ts +39 -0
  168. package/dist/types/lib/detach/drivers/microtaskBatch.d.ts +57 -0
  169. package/dist/types/lib/detach/drivers/sendBeacon.d.ts +38 -0
  170. package/dist/types/lib/detach/drivers/setImmediate.d.ts +32 -0
  171. package/dist/types/lib/detach/drivers/setTimeout.d.ts +34 -0
  172. package/dist/types/lib/detach/drivers/workerThread.d.ts +50 -0
  173. package/dist/types/lib/detach/flush.d.ts +62 -0
  174. package/dist/types/lib/detach/handle.d.ts +83 -0
  175. package/dist/types/lib/detach/registry.d.ts +82 -0
  176. package/dist/types/lib/detach/runChild.d.ts +41 -0
  177. package/dist/types/lib/detach/spawn.d.ts +64 -0
  178. package/dist/types/lib/detach/types.d.ts +200 -0
  179. package/dist/types/lib/engine/traversal/FlowchartTraverser.d.ts +0 -1
  180. package/dist/types/lib/engine/types.d.ts +0 -1
  181. package/dist/types/lib/reactive/types.d.ts +4 -0
  182. package/dist/types/lib/recorder/BoundaryStateTracker.d.ts +215 -0
  183. package/dist/types/lib/recorder/index.d.ts +1 -0
  184. package/dist/types/lib/runner/FlowChartExecutor.d.ts +28 -0
  185. package/dist/types/lib/scope/ScopeFacade.d.ts +4 -0
  186. package/dist/types/lib/scope/state/zod/utils/validateHelper.d.ts +13 -1
  187. package/dist/types/trace.d.ts +1 -0
  188. package/package.json +6 -1
package/AGENTS.md CHANGED
@@ -1,134 +1,619 @@
1
- # footprint.js — Agent Instructions
1
+ # footprint.js — AI Coding Instructions
2
2
 
3
3
  This is the footprint.js library — the flowchart pattern for backend code. Self-explainable systems that AI can reason about.
4
4
 
5
5
  ## Core Principle
6
6
 
7
- **Collect during traversal, never post-process.** All data collection happens as side effects of the single DFS traversal. Never walk the tree after execution.
7
+ **Collect during traversal, never post-process.** All data collection (narrative, metrics, manifest, identity) happens as side effects of the single DFS traversal pass. Never walk the tree again after execution.
8
8
 
9
- ## Architecture
9
+ ## Architecture — Library of Libraries
10
10
 
11
11
  ```
12
12
  src/lib/
13
- ├── memory/ → Transactional state (SharedMemory, StageContext, TransactionBuffer)
14
- ├── schema/ → Validation (Zod optional, duck-typed)
15
- ├── builder/ → Fluent DSL (FlowChartBuilder, flowChart())
13
+ ├── memory/ → Transactional state (SharedMemory, StageContext, TransactionBuffer, EventLog)
14
+ ├── schema/ → Validation abstraction (Zod optional, duck-typed detection)
15
+ ├── builder/ → Fluent DSL (FlowChartBuilder, flowChart(), DeciderList, SelectorFnList)
16
16
  ├── scope/ → Per-stage facades + recorders + providers
17
- ├── reactive/ → TypedScope<T> deep Proxy (typed property access, $-methods)
18
- ├── decide/ → decide()/select() decision evidence capture
19
- ├── recorder/ → CompositeRecorder, KeyedRecorder<T> base class
20
- ├── engine/ DFS traversal + narrative + handlers
21
- ├── runner/ → FlowChartExecutor
22
- └── contract/ I/O schema + OpenAPI
17
+ ├── reactive/ → TypedScope<T> deep Proxy (typed property access, $-methods, cycle-safe)
18
+ ├── decide/ → decide()/select() decision evidence capture (filter + function)
19
+ ├── recorder/ → CompositeRecorder, KeyedRecorder<T>, SequenceRecorder<T>, BoundaryStateTracker<TState>, composition primitives
20
+ ├── pause/ Pause/Resume (PauseSignal, FlowchartCheckpoint, PausableHandler)
21
+ ├── engine/ → DFS traversal + narrative + 13 handlers
22
+ ├── runner/ High-level executor (FlowChartExecutor)
23
+ └── contract/ → I/O schema + OpenAPI generation
23
24
  ```
24
25
 
25
- Entry points: `footprintjs` (public), `footprintjs/trace` (execution tracing), `footprintjs/advanced` (internals).
26
+ Dependency DAG: `memory <- scope <- reactive <- engine <- runner`, `schema <- engine`, `builder (standalone) -> engine`, `contract <- schema`, `decide -> scope`
26
27
 
27
- ## Key API — TypedScope (Recommended)
28
+ Three entry points:
29
+ - `import { ... } from 'footprintjs'` — public API
30
+ - `import { ... } from 'footprintjs/trace'` — execution tracing: runtimeStageId, commitLog queries, KeyedRecorder, SequenceRecorder, BoundaryStateTracker
31
+ - `import { ... } from 'footprintjs/advanced'` — engine internals (also re-exports trace)
32
+
33
+ ## Key API
34
+
35
+ ### TypedScope (Recommended)
28
36
 
29
37
  ```typescript
30
- import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
38
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
31
39
 
32
- interface State {
33
- creditScore: number;
34
- riskTier: string;
35
- decision?: string;
40
+ interface LoanState {
41
+ creditTier: string;
42
+ amount: number;
43
+ customer: { name: string; address: { zip: string } };
44
+ tags: string[];
45
+ approved?: boolean;
36
46
  }
37
47
 
38
- const chart = flowChart<State>('Intake', async (scope) => {
39
- scope.creditScore = 750; // typed write (no setValue needed)
40
- scope.riskTier = 'low'; // typed write
48
+ const chart = flowChart<LoanState>('Intake', async (scope) => {
49
+ scope.creditTier = 'A'; // typed write
50
+ scope.amount = 50000; // typed write
51
+ scope.customer.address.zip = '90210'; // deep write (updateValue)
52
+ scope.tags.push('vip'); // array copy-on-write (single push)
53
+ scope.$batchArray('tags', (arr) => { // O(1) batch: 1 clone + 1 commit
54
+ arr.push('vip', 'premium', 'verified');
55
+ });
56
+ scope.approved = true; // optional field
57
+
58
+ // $-prefixed escape hatches
59
+ scope.$debug('checkpoint', { step: 1 });
60
+ scope.$metric('latency', 42);
61
+ const args = scope.$getArgs<{ requestId: string }>();
62
+ const env = scope.$getEnv();
63
+ scope.$break(); // stop pipeline
41
64
  }, 'intake')
42
- .addDeciderFunction('Route', (scope) => {
43
- return decide(scope, [
44
- { when: { riskTier: { eq: 'low' } }, then: 'approved', label: 'Low risk' },
45
- ], 'rejected');
46
- }, 'route', 'Route based on risk')
47
- .addFunctionBranch('approved', 'Approve', async (scope) => {
48
- scope.decision = 'Approved';
49
- })
50
- .addFunctionBranch('rejected', 'Reject', async (scope) => {
51
- scope.decision = 'Rejected';
52
- })
53
- .setDefault('rejected')
54
- .end()
55
65
  .build();
56
66
 
57
67
  const executor = new FlowChartExecutor(chart);
58
- await executor.run();
59
- executor.getNarrative(); // causal trace with decision evidence
68
+ await executor.run({ input: { requestId: 'req-123' } });
60
69
  ```
61
70
 
62
- ### TypedScope $-methods (escape hatches)
71
+ ### decide() / select() — Decision Evidence Capture
63
72
 
64
73
  ```typescript
65
- scope.$getArgs<T>() // frozen readonly input
66
- scope.$getEnv() // execution environment (signal, timeoutMs, traceId)
67
- scope.$break() // stop pipeline
68
- scope.$debug(key, value) // debug info
69
- scope.$metric(name, value) // metrics
74
+ import { decide, select } from 'footprintjs';
75
+
76
+ // Inside a decider function — auto-captures which values led to the decision
77
+ .addDeciderFunction('ClassifyRisk', (scope) => {
78
+ return decide(scope, [
79
+ { when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'approved', label: 'Good credit' },
80
+ { when: (s) => s.creditScore > 600, then: 'manual-review', label: 'Marginal' },
81
+ ], 'rejected');
82
+ }, 'classify-risk')
83
+
84
+ // Narrative: "It evaluated Rule 0 'Good credit': creditScore 750 gt 700, and chose approved."
70
85
  ```
71
86
 
72
- ### decide() / select()
87
+ ### Builder
73
88
 
74
89
  ```typescript
75
- // Filter syntax captures operators + thresholds
76
- decide(scope, [
77
- { when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'approved', label: 'Good credit' },
78
- ], 'rejected');
90
+ import { flowChart, FlowChartBuilder } from 'footprintjs';
91
+
92
+ const chart = flowChart('Stage1', fn1, 'stage-1', undefined, 'Description')
93
+ .addFunction('Stage2', fn2, 'stage-2', 'Description')
94
+ .addDeciderFunction('Decide', deciderFn, 'decide', 'Route based on risk')
95
+ .addFunctionBranch('high', 'Reject', rejectFn)
96
+ .addFunctionBranch('low', 'Approve', approveFn)
97
+ .setDefault('high')
98
+ .end()
99
+ .build();
100
+ ```
101
+
102
+ Methods: `start()`, `addFunction()`, `addStreamingFunction()`, `addDeciderFunction()`, `addSelectorFunction()`, `addListOfFunction()`, `addPausableFunction()`, `addSubFlowChart()`, `addSubFlowChartNext()`, `loopTo()`, `contract()`, `build()`, `toSpec()`, `toMermaid()`
79
103
 
80
- // Function syntaxcaptures which keys were read
81
- decide(scope, [
82
- { when: (s) => s.creditScore > 700, then: 'approved' },
83
- ], 'rejected');
104
+ ### ScopeFacade (Internaluse TypedScope for new code)
84
105
 
85
- // select() — all matching branches (not first-match)
86
- select(scope, [
87
- { when: { glucose: { gt: 100 } }, then: 'diabetes' },
88
- { when: { bmi: { gt: 30 } }, then: 'obesity' },
89
- ]);
106
+ ```typescript
107
+ scope.getValue('key') // tracked read
108
+ scope.setValue('key', value) // tracked write
109
+ scope.getArgs<T>() // frozen readonly input (NOT tracked)
110
+ scope.getEnv() // frozen execution environment (NOT tracked)
90
111
  ```
91
112
 
113
+ **Three access tiers:**
114
+ - `getValue`/`setValue` — mutable shared state, tracked in narrative
115
+ - `getArgs()` — frozen business input from `run({ input })`, NOT tracked
116
+ - `getEnv()` — frozen infrastructure context from `run({ env })`, NOT tracked. Returns `ExecutionEnv { signal?, timeoutMs?, traceId? }`. Auto-inherited by subflows. Closed type.
117
+
92
118
  ### Executor
93
119
 
94
120
  ```typescript
95
121
  const executor = new FlowChartExecutor(chart);
96
- await executor.run({ input, env: { traceId: 'req-123' } });
97
- executor.getNarrative() // string[]
98
- executor.getNarrativeEntries() // CombinedNarrativeEntry[]
99
- executor.getSnapshot() // memory state
100
- executor.attachRecorder(recorder) // scope observer
101
- executor.attachFlowRecorder(r) // flow observer
102
- executor.setRedactionPolicy({ keys, patterns, fields })
122
+ // With options (preferred over positional params):
123
+ const executor = new FlowChartExecutor(chart, { scopeFactory: myFactory, enrichSnapshots: true });
124
+ await executor.run({ input: data, env: { traceId: 'req-123' } });
125
+
126
+ executor.attachRecorder(recorder) // plug scope observer
127
+ executor.getNarrative() // combined flow + data narrative
128
+ executor.getNarrativeEntries() // structured entries with type/depth/stageName/stageId
129
+ executor.getFlowNarrative() // flow-only (no data ops)
130
+ executor.getSnapshot() // full memory state (includes recorder snapshots)
131
+ executor.attachFlowRecorder(r) // plug flow observer
132
+ executor.setRedactionPolicy({}) // PII protection
133
+
134
+ // Pause/Resume — human-in-the-loop
135
+ executor.isPaused() // true if last run paused
136
+ executor.getCheckpoint() // JSON-safe checkpoint (store in Redis/Postgres/etc.)
137
+ executor.resume(checkpoint, input) // continue from checkpoint with human's answer
103
138
  ```
104
139
 
105
- ## Observer Systems
140
+ ### Pause/Resume (Human-in-the-Loop)
141
+
142
+ ```typescript
143
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
144
+ import type { PausableHandler } from 'footprintjs';
145
+
146
+ const handler: PausableHandler<MyState> = {
147
+ execute: async (scope) => {
148
+ // Return data = pause. Return nothing = continue.
149
+ return { question: `Approve $${scope.amount}?` };
150
+ },
151
+ resume: async (scope, input) => {
152
+ scope.approved = input.approved;
153
+ },
154
+ };
155
+
156
+ // Pausable root stage (single-stage subflows):
157
+ const chart = flowChart<MyState>('Approve', handler, 'approve').build();
158
+
159
+ // Or chained after other stages:
160
+ const chart2 = flowChart<MyState>('Seed', seedFn, 'seed')
161
+ .addPausableFunction('Approve', handler, 'approve')
162
+ .addFunction('Process', processFn, 'process')
163
+ .build();
164
+
165
+ const executor = new FlowChartExecutor(chart);
166
+ await executor.run();
167
+
168
+ if (executor.isPaused()) {
169
+ const checkpoint = executor.getCheckpoint(); // JSON-safe, store anywhere
170
+ // Later (hours, different server):
171
+ await executor.resume(checkpoint, { approved: true });
172
+ }
173
+ ```
174
+
175
+ - `execute` returns data → pauses. Returns void → continues normally (conditional pause).
176
+ - Checkpoint is JSON-serializable — no functions, no class instances.
177
+ - `resume()` reuses the execution runtime — narrative, metrics, execution tree all accumulate.
178
+ - `FlowRecorder.onPause`/`onResume` and `Recorder.onPause`/`onResume` fire on both observer systems.
179
+
180
+ ### ComposableRunner & Snapshot Navigation
181
+
182
+ ```typescript
183
+ import type { ComposableRunner } from 'footprintjs';
184
+ import { getSubtreeSnapshot, listSubflowPaths } from 'footprintjs';
185
+
186
+ const subtree = getSubtreeSnapshot(snapshot, 'sf-payment');
187
+ listSubflowPaths(snapshot); // ['sf-payment', 'sf-outer/sf-inner']
188
+ ```
189
+
190
+ ## Two Observer Systems
191
+
192
+ Both use `{ id, hooks } -> dispatcher -> error isolation -> attach/detach`. Intentionally NOT unified.
193
+
194
+ **Recorder ID contract:**
195
+ - `attachRecorder` is **idempotent by ID** — same ID replaces, different IDs coexist. Prevents accidental double-counting.
196
+ - Built-in recorders use auto-increment default IDs (`metrics-1`, `debug-1`, ...) so multiple instances with different configs coexist naturally.
197
+ - Frameworks that auto-attach recorders should use a well-known ID (e.g., `new MetricRecorder('metrics')`) so the consumer can override it by passing the same ID, or add a second instance with `new MetricRecorder()` (gets unique ID).
198
+
199
+ **Scope Recorder** (data ops — fires DURING stage execution):
200
+ - `onRead`, `onWrite`, `onCommit`, `onError`, `onStageStart`, `onStageEnd`
201
+ - Built-in: `MetricRecorder`, `DebugRecorder`
202
+
203
+ **FlowRecorder** (control flow — fires AFTER stage execution):
204
+ - `onStageExecuted`, `onNext`, `onDecision`, `onFork`, `onSelected`, `onSubflowEntry/Exit`, `onLoop`, `onBreak`, `onError`
205
+ - All events carry `traversalContext: TraversalContext`
206
+ - `onDecision`/`onSelected` carry optional `evidence` from decide()/select()
207
+ - Built-in: 8 strategies (Narrative, Adaptive, Windowed, RLE, Milestone, Progressive, Separate, Manifest, Silent)
106
208
 
107
- - **Scope Recorder**: fires DURING stage (`onRead`, `onWrite`, `onCommit`)
108
- - **FlowRecorder**: fires AFTER stage (`onStageExecuted`, `onDecision`, `onFork`, `onLoop`)
109
- - 8 built-in FlowRecorder strategies
110
- - Narrative via `executor.recorder(narrative())` at runtime
209
+ **CombinedNarrativeRecorder** implements BOTH interfaces. Attached via `executor.recorder(narrative())` at runtime.
210
+
211
+ ## Event Ordering
212
+
213
+ ```
214
+ 1. Recorder.onStageStart — stage begins
215
+ 2. Recorder.onRead/onWrite — DURING execution (buffered per-stage)
216
+ 3. Recorder.onCommit — transaction flush
217
+ 4. Recorder.onStageEnd — stage completes
218
+ 5. FlowRecorder.onStageExecuted — CombinedNarrativeRecorder flushes buffered ops
219
+ 6. FlowRecorder.onNext/onDecision/onFork — control flow continues
220
+ ```
111
221
 
112
222
  ## Execution Tracing (`footprintjs/trace`)
113
223
 
114
- Every stage gets a unique `runtimeStageId`: `[subflowPath/]stageId#executionIndex` (e.g., `call-llm#5`, `sf-tools/execute-tool-calls#8`)
224
+ Every stage execution gets a unique `runtimeStageId` the universal key that links recorder events, commit log entries, and execution tree nodes.
225
+
226
+ **When to use:** Debugging (which stage set a value to something unexpected?), audit trails (trace every write to its source stage), custom recorders (correlate events with specific execution steps), quality trace backtracking (walk backwards to find where data quality dropped).
115
227
 
116
- Use for: debugging (which stage wrote what?), backtracking (who changed a value?), custom recorder storage.
228
+ **Format:** `[subflowPath/]stageId#executionIndex`
229
+
230
+ ```
231
+ seed#0 — root stage
232
+ call-llm#5 — 5th execution step
233
+ sf-tools/execute-tool-calls#8 — subflow stage
234
+ call-llm#9 — same stageId, different execution (loop)
235
+ ```
236
+
237
+ **The commitLog:** An ordered array of `CommitBundle` — one per stage commit, recording what each stage wrote to shared state. Get it from `executor.getSnapshot().commitLog`.
117
238
 
118
239
  ```typescript
119
- import { parseRuntimeStageId, findLastWriter, findCommit, KeyedRecorder } from 'footprintjs/trace';
240
+ import { parseRuntimeStageId, findLastWriter, findCommit } from 'footprintjs/trace';
241
+
242
+ // Parse a runtimeStageId into components
243
+ parseRuntimeStageId('sf-tools/execute-tool-calls#8');
244
+ // → { stageId: 'execute-tool-calls', executionIndex: 8, subflowPath: 'sf-tools' }
120
245
 
121
- // commitLog from executor.getSnapshot().commitLog (CommitBundle[])
122
- // findCommit(commitLog, stageId, key?) → CommitBundle | undefined
123
- // findLastWriter(commitLog, key, beforeIdx?) CommitBundle | undefined — search backwards
124
- // KeyedRecorder<T> — abstract base: store(runtimeStageId, entry), getByKey(), getMap(), values()
246
+ // Get the commit log after execution
247
+ const snapshot = executor.getSnapshot();
248
+ const commitLog = snapshot.commitLog; // CommitBundle[]
249
+
250
+ // Backtrack: who last wrote 'systemPrompt' before commitLog array index 8?
251
+ // beforeIdx is the CommitBundle.idx (array position), NOT the executionIndex from runtimeStageId.
252
+ const writer = findLastWriter(commitLog, 'systemPrompt', 8);
253
+ // → CommitBundle | undefined (has .stage, .stageId, .runtimeStageId, .trace, .overwrite, .updates)
254
+
255
+ // Find by stageId: use findCommit when you know the stage.
256
+ // Use findLastWriter when you know the key but not which stage wrote it.
257
+ const llmCommit = findCommit(commitLog, 'call-llm', 'adapterRawResponse');
258
+ ```
259
+
260
+ **Exports from `footprintjs/trace`:**
261
+
262
+ | Export | Returns | Use |
263
+ |--------|---------|-----|
264
+ | `buildRuntimeStageId(stageId, idx, subflowPath?)` | `string` | Construct an ID from components |
265
+ | `parseRuntimeStageId(id)` | `{ stageId, executionIndex, subflowPath }` | Decompose an ID |
266
+ | `findCommit(commitLog, stageId, key?)` | `CommitBundle \| undefined` | Find first commit by stageId |
267
+ | `findCommits(commitLog, stageId)` | `CommitBundle[]` | Find all commits by stageId |
268
+ | `findLastWriter(commitLog, key, beforeIdx?)` | `CommitBundle \| undefined` | Search backwards for who wrote a key |
269
+ | `KeyedRecorder<T>` | abstract class | Base for 1:1 Map-based recorders (durable) |
270
+ | `SequenceRecorder<T>` | abstract class | Base for 1:N ordered sequence recorders (durable; has `getEntryRanges()` for O(1) time-travel) |
271
+ | `BoundaryStateTracker<TState>` | abstract class | Base for transient bracket-scoped state — live state DURING a `[start, stop]` interval; clears on stop. O(1) reads via `getActive` / `hasActive` / `activeCount`. Subclass calls `startBoundary` / `updateBoundary` / `stopBoundary` from observer hooks. |
272
+ | `topologyRecorder()` / `TopologyRecorder` | factory / class | Live composition graph for streaming consumers (subflow nodes + control-flow edges) |
273
+ | `inOutRecorder()` / `InOutRecorder` | factory / class | Chart in/out stream — `entry`/`exit` pairs at every chart boundary (top-level run + every subflow) |
274
+
275
+ ### TopologyRecorder — Composition Graph for Streaming Consumers
276
+
277
+ **One-liner:** reconstructs a live, queryable mini-flowchart of what your run actually traced, built from the 3 primitive recorder channels during traversal.
278
+
279
+ **Mental model:**
280
+
281
+ ```
282
+ flowChart() builder → STATIC flowchart (design-time definition)
283
+
284
+ ▼ executor runs it
285
+ Traversal emits events on 3 channels:
286
+ Recorder · FlowRecorder · EmitRecorder
287
+
288
+ ▼ TopologyRecorder listens
289
+ DYNAMIC flowchart (runtime shape):
290
+ Nodes = composition points
291
+ (subflow / fork-branch / decision-branch)
292
+ Edges = transitions
293
+ (next / fork / decision / loop)
294
+ Queryable any moment — during or after run
125
295
  ```
126
296
 
127
- ## Rules
297
+ **What it IS:**
298
+ - Live composition graph derived from 3 primitive channels
299
+ - Each node = one composition-significant moment (subflow entered, fork child, decision chosen)
300
+ - Each edge = a control-flow transition, timestamped with `runtimeStageId`
301
+ - Works identically during or after a run
302
+
303
+ **What it ISN'T:**
304
+ - Not a full execution tree — that's `StageContext` / `executor.getSnapshot()`
305
+ - Not per-stage data — that's `MetricRecorder` / custom `KeyedRecorder<T>`
306
+ - Not agent-specific — agentfootprint composes it; footprintjs owns it
307
+
308
+ **Why live consumers need it:** The executor already has the topology internally (execution tree in `StageContext`). But streaming consumers can't access that tree mid-run — they only see events. `TopologyRecorder` = "the tree, reconstructed from events, live-queryable."
309
+
310
+ Fills the gap between "post-run snapshot (full tree available)" and "live event stream (only point observations)." Attach once; query `getTopology()` anytime during or after a run.
311
+
312
+ ```typescript
313
+ import { topologyRecorder } from 'footprintjs/trace';
314
+
315
+ const topo = topologyRecorder();
316
+ executor.attachCombinedRecorder(topo); // auto-routes to FlowRecorder channel
317
+
318
+ await executor.run({ input });
319
+
320
+ const { nodes, edges, activeNodeId, rootId } = topo.getTopology();
321
+ topo.getSubflowNodes(); // agent-centric view
322
+ topo.getByKind('fork-branch'); // all parallel branches
323
+ topo.getParallelSiblings(id); // siblings of a parallel branch
324
+ ```
325
+
326
+ **Three node kinds — complete composition coverage:**
327
+
328
+ | Kind | Fires on | Represents |
329
+ |---|---|---|
330
+ | `subflow` | `onSubflowEntry` | Mounted subflow boundary (with stable `subflowId`) |
331
+ | `fork-branch` | `onFork` (synthesized one per child) | One branch of a parallel split — works for plain stages AND subflows |
332
+ | `decision-branch` | `onDecision` (synthesized for chosen) | The chosen branch of a conditional |
333
+
334
+ When a fork-branch or decision-branch target is also a subflow, the subsequent `onSubflowEntry` creates a subflow CHILD of the synthetic node. Layered shape preserves both "who branched" and "what the branch ran."
335
+
336
+ **Edges:** one per control-flow transition. `edge.kind ∈ 'next' | 'fork-branch' | 'decision-branch' | 'loop-iteration'`. Each carries `at: runtimeStageId` for time correlation.
337
+
338
+ **Correlation rules:**
339
+ - `onFork({ parent, children })` → N `fork-branch` nodes synthesized up-front; subsequent matching `onSubflowEntry` nests under the right fork-branch
340
+ - `onDecision({ chosen })` → `decision-branch` node synthesized up-front; matching `onSubflowEntry` nests under it
341
+ - Pending correlation clears on `onSubflowExit` so state doesn't leak across scopes
342
+ - `onLoop` → self-edge on the currently-active subflow (synthetic nodes don't participate)
343
+ - Re-entry of same `subflowId` (loop body) disambiguates via `id#n` suffix
344
+
345
+ **What it does NOT track:** plain sequential stages. Use `MetricRecorder` / `StageContext` for per-stage data. Topology is a graph of control-flow branching, not a full execution tree.
346
+
347
+ **For downstream libraries:** compose, don't duplicate. An agent-shaped recorder should wrap a `topologyRecorder()` internally and translate topology nodes into agent semantics — not re-implement subflow-stack + fork + decision tracking.
348
+
349
+ Example: [examples/runtime-features/flow-recorder/06-topology.ts](examples/runtime-features/flow-recorder/06-topology.ts)
350
+
351
+ ### InOutRecorder — Chart In/Out Stream (every chart boundary, root + subflows)
352
+
353
+ **One-liner:** captures every chart execution (top-level run AND every subflow) as an `entry`/`exit` boundary pair, with the `inputMapper`/`outputMapper` payloads attached. Combined with `TopologyRecorder` (composition shape) this gives downstream layers the universal "step" primitive — `runtimeStageId` binds them.
354
+
355
+ **Mental model:**
356
+
357
+ ```
358
+ user input ─►┌───────────────── run ─────────────────┐ ◄─ user output
359
+ │ __root__#0 onRunStart / onRunEnd │
360
+ │ │
361
+ │ inputMapper outputMapper │
362
+ │ │ │ │
363
+ │ parent ──►┤ subflow ├──► parent │
364
+ │ │ │ │
365
+ │ └── runtimeStageId ───┘ │
366
+ │ │
367
+ └────────────────────────────────────────┘
368
+ ```
369
+
370
+ Each chart execution → 2 boundaries:
371
+ - **Root** — `onRunStart` / `onRunEnd` fire ONCE per `executor.run()`. `subflowId: '__root__'`, `depth: 0`, `isRoot: true`.
372
+ - **Subflow** — `onSubflowEntry` / `onSubflowExit` fire once per mounted subflow. Nested under root in the path tree (`['__root__', 'sf-x']`, depth 1+).
373
+
374
+ Loop re-entry produces distinct pairs because the parent stage's executionIndex increments.
375
+
376
+ **What it IS:**
377
+ - `SequenceRecorder<InOutEntry>` — flat ordered list + per-`runtimeStageId` index
378
+ - Captures the **payloads** at every chart boundary (what flowed IN and OUT)
379
+ - Path-aware: `subflowPath` is decomposed from the engine's path-prefixed `subflowId` and rooted under `__root__`
380
+ - Domain-agnostic — knows nothing about LLMs, tools, agents
381
+
382
+ **What it ISN'T:**
383
+ - Not a composition graph — that's `TopologyRecorder` (shape) vs this (data crossing each boundary)
384
+ - Not a full execution tree — that's `StageContext`
385
+ - Not agent-specific — domain libraries (e.g. agentfootprint) compose it; footprintjs owns it
386
+
387
+ ```typescript
388
+ import { inOutRecorder, ROOT_SUBFLOW_ID } from 'footprintjs/trace';
389
+
390
+ const inOut = inOutRecorder();
391
+ executor.attachCombinedRecorder(inOut);
392
+
393
+ await executor.run({ input });
394
+
395
+ inOut.getSteps(); // entry boundaries (timeline; root is first step)
396
+ inOut.getBoundary(runtimeStageId); // { entry, exit } pair for one execution
397
+ inOut.getRootBoundary(); // { entry, exit } for the top-level run
398
+ inOut.getBoundaries(); // flat list (entry+exit interleaved)
399
+ inOut.getEntryRanges(); // O(1) per-step range index for time-travel
400
+ ```
401
+
402
+ **`InOutEntry` shape:**
403
+
404
+ | Field | Description |
405
+ |---|---|
406
+ | `runtimeStageId` | Same value for the entry/exit pair of one execution. Top-level run uses `'__root__#0'`. |
407
+ | `subflowId` | Path-prefixed engine id. Top-level → `'__root__'`. Subflow → `'sf-outer'` or `'sf-outer/sf-inner'`. |
408
+ | `localSubflowId` | Last segment of `subflowId` |
409
+ | `subflowName` | Human-readable display name (`'Run'` for the top-level run) |
410
+ | `description` | Build-time description (carries taxonomy markers like `'Agent: ReAct loop'`). Undefined for root. |
411
+ | `subflowPath` | Decomposition of `subflowId` rooted under `__root__`: `['__root__']` for root, `['__root__', 'sf-x']` for top-level subflow |
412
+ | `depth` | Root → 0. First-level subflow → 1. |
413
+ | `phase` | `'entry'` or `'exit'` |
414
+ | `payload` | `entry`: `inputMapper` result (subflow) or `run({input})` (root); `exit`: shared state at exit (subflow) or chart return value (root) |
415
+ | `isRoot` | True only for the synthetic root pair from `onRunStart` / `onRunEnd` |
416
+
417
+ **Pause semantics:** when a stage pauses inside a subflow, the engine re-throws without firing `onSubflowExit` (or `onRunEnd`). The chart has an `entry` with no matching `exit` until resume completes. `getBoundary()` returns `{ entry, exit: undefined }` in that case.
418
+
419
+ **Engine events:** `FlowRecorder.onRunStart(event)` and `onRunEnd(event)` carry `event.payload` (the run's input or output). Fire ONCE per top-level `executor.run()` — not for subflow traversers (those fire `onSubflowEntry`/`onSubflowExit` instead). Available on the `IControlFlowNarrative` interface and the `FlowRecorderDispatcher`.
420
+
421
+ **For downstream libraries:** compose, don't duplicate. A domain-flavored step graph (e.g., agentfootprint's `StepGraph`) should consume `InOutRecorder` output and label each entry by inspecting the payload through domain semantics — not re-walk subflow events.
422
+
423
+ Example: [examples/runtime-features/flow-recorder/07-inout.ts](examples/runtime-features/flow-recorder/07-inout.ts)
424
+
425
+ **Three recorder storage primitives** — choose based on data shape and durability:
426
+
427
+ | Base Class | Relationship | Time scope | Use When |
428
+ |------------|-------------|------------|----------|
429
+ | `KeyedRecorder<T>` | 1:1 Map | durable | Each step produces one record (MetricRecorder, TokenRecorder) |
430
+ | `SequenceRecorder<T>` | 1:N sequence + Map | durable | Multiple records per step, ordering matters (CombinedNarrativeRecorder) |
431
+ | `BoundaryStateTracker<TState>` | Map\<key, TState\> active stack | transient — clears on stop | Live state DURING a `[start, stop]` bracket (LLM stream partial, tool args streaming) |
432
+
433
+ A real recorder picks ONE observer interface AND ONE storage shelf, combining via `extends + implements`. Existing example: `BoundaryRecorder extends SequenceRecorder<DomainEvent> implements CombinedRecorder`.
434
+
435
+ ```typescript
436
+ import { KeyedRecorder, SequenceRecorder } from 'footprintjs/trace';
437
+
438
+ // KeyedRecorder: one entry per step
439
+ class TokenRecorder extends KeyedRecorder<TokenEntry> {
440
+ readonly id = 'tokens';
441
+ onLLMCall(event) { this.store(event.runtimeStageId, { tokens: event.usage }); }
442
+ }
443
+ recorder.getByKey('call-llm#5'); // Translate: per-step value
444
+ recorder.aggregate((sum, e) => sum + e.tokens, 0); // Aggregate: grand total
445
+ recorder.accumulate((sum, e) => sum + e.tokens, 0, visibleKeys); // Accumulate: up to slider
446
+
447
+ // SequenceRecorder: multiple entries per step, ordered
448
+ class AuditRecorder extends SequenceRecorder<AuditEntry> {
449
+ readonly id = 'audit';
450
+ onRead(event) { this.emit({ runtimeStageId: event.runtimeStageId, type: 'read', key: event.key }); }
451
+ onDecision(event) { this.emit({ runtimeStageId: event.traversalContext?.runtimeStageId, ... }); }
452
+ }
453
+ recorder.getEntriesForStep('call-llm#5'); // Translate: per-step entries
454
+ recorder.aggregate((count, _) => count + 1, 0); // Aggregate: grand total
455
+ recorder.getEntriesUpTo(visibleKeys); // Progressive: up to slider
456
+ recorder.getEntryRanges(); // Range index: O(1) slider sync
457
+
458
+ // BoundaryStateTracker: transient state DURING a bracket; clears on stop
459
+ class LiveLLMTracker extends BoundaryStateTracker<{ partial: string; tokens: number }>
460
+ implements EmitRecorder
461
+ {
462
+ readonly id = 'live-llm';
463
+ onEmit(e) {
464
+ if (e.name === 'llm.start') this.startBoundary(e.runtimeStageId, { partial: '', tokens: 0 });
465
+ if (e.name === 'llm.token') this.updateBoundary(e.runtimeStageId, s => ({ partial: s.partial + e.payload.content, tokens: s.tokens + 1 }));
466
+ if (e.name === 'llm.end') this.stopBoundary(e.runtimeStageId);
467
+ }
468
+ isInFlight(): boolean { return this.hasActive; }
469
+ getPartial(rid: string): string { return this.getActive(rid)?.partial ?? ''; }
470
+ }
471
+ tracker.isInFlight(); // O(1) — live read
472
+ tracker.getActive(rid); // O(1) — current state of one boundary
473
+ tracker.activeCount; // O(1) — how many concurrent boundaries
474
+ // Lifecycle: clear() between runs; dev-mode warns on leaked-stop bugs.
475
+ ```
476
+
477
+ **`getEntryRanges()`** returns a precomputed `Map<runtimeStageId, {firstIdx, endIdx}>` maintained during `emit()`. Use for O(1) per-step range lookups during time-travel scrubbing. Same shape as `buildEntryRangeIndex()` in `footprint-explainable-ui`.
478
+
479
+ **`CombinedNarrativeEntry.direction`** — subflow entries carry `direction: 'entry' | 'exit'`. Use for programmatic subflow boundary detection instead of text scanning (which breaks with custom `NarrativeRenderer`).
480
+
481
+ **`footprint-explainable-ui` narrative utilities** — for consumers building custom shells without `ExplainableShell`:
482
+ - `buildEntryRangeIndex(entries)` — build range index from flat array (when no recorder access)
483
+ - `computeRevealedEntryCount(entries, snapshots, idx, rangeIndex?)` — slider position → entry count
484
+ - `extractSubflowNarrative(entries, subflowId)` — three-tier subflow entry extraction
485
+
486
+ **How runtimeStageId is generated:** A counter starts at 0 and increments by 1 for each stage execution across the entire run, including subflow stages. Subflow child traversers share the parent counter so indices are globally unique. Stages inside subflows have stageIds already prefixed by the builder (e.g., `sf-tools/execute-tool-calls`), so `buildRuntimeStageId` just appends `#index`.
487
+
488
+ ## Dev Mode
489
+
490
+ One global flag (`enableDevMode()` / `disableDevMode()` / `isDevMode()`) controls every developer-only diagnostic across the library. OFF by default — production pays zero overhead.
491
+
492
+ ```ts
493
+ import { enableDevMode } from 'footprintjs';
494
+ if (process.env.NODE_ENV !== 'production') enableDevMode();
495
+ ```
496
+
497
+ Gated diagnostics:
498
+ - **Circular-ref detection** in `ScopeFacade.setValue()` — O(n) WeakSet traversal per write
499
+ - **Empty-recorder warning** in `attachCombinedRecorder(r)` — catches `r` with no `on*` handler
500
+ - **Suspicious predicates** in `decide()` / `select()`
501
+ - **Snapshot integrity** in `getSubtreeSnapshot()`
502
+
503
+ Convention: when adding a new dev-only check, gate on `isDevMode()` (from `scope/detectCircular.ts`). Do NOT use `process.env.NODE_ENV` inline — consumers control dev tooling centrally via `enableDevMode()`/`disableDevMode()`, and inline env checks break that contract.
504
+
505
+ ## Break + Propagation
506
+
507
+ `scope.$break(reason?)` takes an optional free-form reason string that surfaces on `FlowBreakEvent.reason`. Recorders and narrative consumers see it.
508
+
509
+ By default, an inner subflow's `$break` stops ONLY the subflow; the parent continues. Opt into propagation via `SubflowMountOptions.propagateBreak: true`:
510
+
511
+ ```ts
512
+ builder.addSubFlowChartNext('sf-escalate', escalateChart, 'Escalate', {
513
+ inputMapper: ..., outputMapper: ...,
514
+ propagateBreak: true, // ← inner $break → parent $break, with reason
515
+ });
516
+ ```
517
+
518
+ Semantics:
519
+ - **Linear chain:** inner `$break(reason)` → parent's `breakFlag` flips → next parent stage does NOT run → `FlowBreakEvent` fires at parent-mount level with `propagatedFromSubflow` + reason.
520
+ - **Nested chain:** propagates through every hop that opted in. Reason survives.
521
+ - **outputMapper still runs** before propagation — subflow's partial state lands in parent before the break. Escape hatch: early-return `{}` from outputMapper when the break state is set.
522
+ - **Parallel/fan-out:** existing ChildrenExecutor rule applies — parent breaks only when ALL fork children broke. `propagateBreak: true` on a single child contributes to that count; doesn't terminate the fork alone.
523
+
524
+ Example: [examples/runtime-features/break/04-subflow-propagate.ts](examples/runtime-features/break/04-subflow-propagate.ts).
525
+
526
+ ## Emit Channel (Phase 3)
527
+
528
+ Third observer channel alongside `Recorder` (data-flow) and `FlowRecorder` (control-flow). Consumer stage code emits structured events; `EmitRecorder.onEmit(event)` fires synchronously with auto-enriched context.
529
+
530
+ ```ts
531
+ import type { EmitRecorder, EmitEvent } from 'footprintjs';
532
+
533
+ // Inside a stage:
534
+ scope.$emit('myapp.llm.tokens', { input: 100, output: 50 });
535
+
536
+ // Recorder observes:
537
+ const rec: EmitRecorder = {
538
+ id: 'token-meter',
539
+ onEmit: (e) => { if (e.name === 'myapp.llm.tokens') tally(e.payload); },
540
+ };
541
+ executor.attachEmitRecorder(rec);
542
+ ```
543
+
544
+ ### Semantics
545
+ - **Pass-through.** Delivered synchronously, in call order. Zero allocation when no recorder attached (fast-path in `ScopeFacade.emitEvent`).
546
+ - **Auto-enriched.** Events carry `stageName`, `runtimeStageId`, `subflowPath`, `pipelineId`, `timestamp` — parsed from `runtimeStageId` for subflow context.
547
+ - **Error-isolated.** A throwing `onEmit` doesn't propagate; errors route to `onError` on other recorders.
548
+ - **Redactable.** `RedactionPolicy.emitPatterns: RegExp[]` matches `event.name`; matched payloads become `'[REDACTED]'` before dispatch.
549
+ - **Buffered in narrative.** `CombinedNarrativeRecorder.onEmit` buffers alongside reads/writes; flushed in `flushOps` so emit entries appear AFTER the stage header in ordered narrative.
550
+
551
+ ### Naming convention
552
+ Hierarchical dotted names — `<namespace>.<category>.<event>`. Examples:
553
+ - `'agentfootprint.llm.tokens'`, `'agentfootprint.llm.request'`
554
+ - `'myapp.billing.spend'`, `'myapp.auth.check'`
555
+
556
+ ### Legacy primitives route through this channel
557
+ `$debug`, `$metric`, `$error`, `$eval`, `$log` also dispatch on the emit channel (in addition to their existing `DiagnosticCollector` side-bag writes for snapshot inclusion):
558
+
559
+ ```
560
+ $debug(key, value) → emits 'log.debug.${key}'
561
+ $error(key, value) → emits 'log.error.${key}'
562
+ $metric(name, value) → emits 'metric.${name}'
563
+ $eval (name, value) → emits 'eval.${name}'
564
+ ```
565
+
566
+ This closes the long-standing gap where `$metric` / `$debug` went to side bags no recorder observed. Backward-compat: the side bags still populate for consumers that inspect snapshots directly.
567
+
568
+ ### Customizing narrative rendering
569
+ `NarrativeFormatter.renderEmit?(ctx)` hook renders an emit event into a narrative line. Return `string` to use, `null` to exclude, `undefined` to fall back to the default `[emit] name: payloadSummary`.
570
+
571
+ Example: [examples/runtime-features/emit/01-custom-events.ts](examples/runtime-features/emit/01-custom-events.ts).
572
+
573
+ ## Combined Recorder
574
+
575
+ A `CombinedRecorder` is an observer that hooks into multiple event streams (scope data-flow, control-flow, AND emit — all three channels). One object, one `id`, one `attachCombinedRecorder()` call — the library routes to the right channels via runtime method-shape detection.
576
+
577
+ ```ts
578
+ import type { CombinedRecorder } from 'footprintjs';
579
+ import { isFlowEvent } from 'footprintjs';
580
+
581
+ const audit: CombinedRecorder = {
582
+ id: 'audit',
583
+ onWrite: (e) => log('scope write', e.key), // Recorder stream
584
+ onDecision: (e) => log('routed to', e.chosen), // FlowRecorder stream
585
+ onError: (e) => {
586
+ // Shared method — union payload. Discriminate with isFlowEvent():
587
+ if (isFlowEvent(e)) log('flow error in', e.stageName);
588
+ else log('scope error during', e.operation);
589
+ },
590
+ };
591
+
592
+ executor.attachCombinedRecorder(audit);
593
+ ```
594
+
595
+ Built on `CombinedRecorder`: `CombinedNarrativeRecorder` (the `executor.enableNarrative()` default). Consumers implement ONLY the events they care about — `Partial<Recorder> & Partial<FlowRecorder>` under the hood.
596
+
597
+ **Detection rule:** only OWN event-method properties count (prototype methods are ignored for security — prevents accidental `Object.prototype` pollution from attaching handlers).
598
+
599
+ ## Anti-Patterns
128
600
 
129
- - Use `flowChart<T>()` — scopeFactory is auto-embedded
130
- - Use `decide()` / `select()` in decider/selector functions
131
- - Use typed property access (not getValue/setValue)
132
- - Use `$getArgs()` for input, `$getEnv()` for environment
133
601
  - Never post-process the tree — use recorders
134
- - Use `.recorder(narrative())` at runtime for narrative setup
602
+ - Don't use `getValue()`/`setValue()` in TypedScope stages use typed property access
603
+ - Don't use `$`-prefixed state keys (e.g., `$break`) — they collide with ScopeMethods
604
+ - Don't use deprecated `CombinedNarrativeBuilder` — use `CombinedNarrativeRecorder`
605
+ - Don't extract shared base for Recorder/FlowRecorder — two instances = coincidence
606
+ - Don't use `getArgs()` for tracked data — use typed scope properties
607
+ - Don't put infrastructure data in `getArgs()` — use `getEnv()` via `run({ env })`
608
+ - Don't manually create `CombinedNarrativeRecorder` — `executor.recorder(narrative())` handles it
609
+ - Don't return full arrays from `outputMapper` without `arrayMerge: ArrayMergeMode.Replace` — default `applyOutputMapping` **concatenates** arrays (`[...parent, ...subflow]`). Either return only the **delta** (new items), or set `arrayMerge: ArrayMergeMode.Replace` on `SubflowMountOptions` to overwrite instead of concatenate. Scalars are always replaced regardless.
610
+
611
+ ## Build & Test
612
+
613
+ ```bash
614
+ npm run build # tsc (CJS) + tsc -p tsconfig.esm.json (ESM)
615
+ npm test # full suite
616
+ npm run test:unit
617
+ ```
618
+
619
+ Dual output: CommonJS (`dist/`) + ESM (`dist/esm/`) + types (`dist/types/`)