footprintjs 9.32.0 → 9.33.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 (108) hide show
  1. package/AGENTS.md +670 -194
  2. package/dist/esm/lib/capture/freeze.d.ts +47 -0
  3. package/dist/esm/lib/capture/freeze.js +60 -0
  4. package/dist/esm/lib/capture/index.d.ts +1 -0
  5. package/dist/esm/lib/capture/index.js +2 -1
  6. package/dist/esm/lib/detach/drivers/immediate.d.ts +25 -24
  7. package/dist/esm/lib/detach/drivers/immediate.js +26 -25
  8. package/dist/esm/lib/detach/drivers/microtaskBatch.d.ts +5 -4
  9. package/dist/esm/lib/detach/drivers/microtaskBatch.js +6 -5
  10. package/dist/esm/lib/memory/EventLog.d.ts +28 -3
  11. package/dist/esm/lib/memory/EventLog.js +31 -4
  12. package/dist/esm/lib/memory/StageContext.js +4 -5
  13. package/dist/esm/lib/memory/TransactionBuffer.d.ts +2 -2
  14. package/dist/esm/lib/memory/TransactionBuffer.js +6 -4
  15. package/dist/esm/lib/memory/backtrack.d.ts +24 -0
  16. package/dist/esm/lib/memory/backtrack.js +52 -41
  17. package/dist/esm/lib/memory/commitLogUtils.d.ts +129 -38
  18. package/dist/esm/lib/memory/commitLogUtils.js +252 -59
  19. package/dist/esm/lib/memory/deltaEncoding.d.ts +1 -1
  20. package/dist/esm/lib/memory/deltaEncoding.js +2 -2
  21. package/dist/esm/lib/memory/honesty.d.ts +5 -1
  22. package/dist/esm/lib/memory/honesty.js +11 -6
  23. package/dist/esm/lib/memory/index.d.ts +2 -2
  24. package/dist/esm/lib/memory/index.js +3 -3
  25. package/dist/esm/lib/memory/keyPaths.d.ts +125 -0
  26. package/dist/esm/lib/memory/keyPaths.js +227 -0
  27. package/dist/esm/lib/memory/logModel.d.ts +76 -0
  28. package/dist/esm/lib/memory/logModel.js +357 -0
  29. package/dist/esm/lib/memory/placeholders.d.ts +3 -3
  30. package/dist/esm/lib/memory/placeholders.js +4 -4
  31. package/dist/esm/lib/memory/redaction.d.ts +34 -1
  32. package/dist/esm/lib/memory/redaction.js +55 -4
  33. package/dist/esm/lib/memory/utils.d.ts +2 -6
  34. package/dist/esm/lib/memory/utils.js +4 -21
  35. package/dist/esm/lib/memory/verbs.d.ts +65 -22
  36. package/dist/esm/lib/memory/verbs.js +64 -23
  37. package/dist/esm/lib/runner/ExecutionRuntime.js +5 -3
  38. package/dist/esm/lib/runner/FlowChartExecutor.js +5 -5
  39. package/dist/esm/lib/scope/protection/readonlyInput.d.ts +2 -6
  40. package/dist/esm/lib/scope/protection/readonlyInput.js +4 -18
  41. package/dist/esm/lib/slice/elementProvenance.d.ts +4 -1
  42. package/dist/esm/lib/slice/elementProvenance.js +33 -15
  43. package/dist/esm/lib/slice/forwardSliceForKey.d.ts +4 -0
  44. package/dist/esm/lib/slice/forwardSliceForKey.js +32 -9
  45. package/dist/esm/lib/slice/keyIndex.d.ts +50 -8
  46. package/dist/esm/lib/slice/keyIndex.js +97 -16
  47. package/dist/esm/lib/slice/keyTimeline.js +11 -6
  48. package/dist/esm/lib/slice/serialize.js +8 -2
  49. package/dist/esm/lib/slice/sliceForKey.js +22 -6
  50. package/dist/esm/lib/slice/types.d.ts +53 -7
  51. package/dist/esm/lib/slice/types.js +1 -1
  52. package/dist/esm/lib/time-travel/stateAt.js +4 -14
  53. package/dist/esm/trace.d.ts +2 -1
  54. package/dist/esm/trace.js +2 -6
  55. package/dist/lib/capture/freeze.js +64 -0
  56. package/dist/lib/capture/index.js +4 -2
  57. package/dist/lib/detach/drivers/immediate.js +26 -25
  58. package/dist/lib/detach/drivers/microtaskBatch.js +6 -5
  59. package/dist/lib/memory/EventLog.js +31 -4
  60. package/dist/lib/memory/StageContext.js +3 -4
  61. package/dist/lib/memory/TransactionBuffer.js +6 -4
  62. package/dist/lib/memory/backtrack.js +52 -41
  63. package/dist/lib/memory/commitLogUtils.js +257 -61
  64. package/dist/lib/memory/deltaEncoding.js +2 -2
  65. package/dist/lib/memory/honesty.js +11 -6
  66. package/dist/lib/memory/index.js +3 -3
  67. package/dist/lib/memory/keyPaths.js +243 -0
  68. package/dist/lib/memory/logModel.js +365 -0
  69. package/dist/lib/memory/placeholders.js +4 -4
  70. package/dist/lib/memory/redaction.js +56 -3
  71. package/dist/lib/memory/utils.js +8 -26
  72. package/dist/lib/memory/verbs.js +64 -23
  73. package/dist/lib/runner/ExecutionRuntime.js +6 -4
  74. package/dist/lib/runner/FlowChartExecutor.js +6 -6
  75. package/dist/lib/scope/protection/readonlyInput.js +6 -21
  76. package/dist/lib/slice/elementProvenance.js +32 -14
  77. package/dist/lib/slice/forwardSliceForKey.js +31 -8
  78. package/dist/lib/slice/keyIndex.js +103 -17
  79. package/dist/lib/slice/keyTimeline.js +10 -5
  80. package/dist/lib/slice/serialize.js +8 -2
  81. package/dist/lib/slice/sliceForKey.js +21 -5
  82. package/dist/lib/slice/types.js +1 -1
  83. package/dist/lib/time-travel/stateAt.js +4 -14
  84. package/dist/trace.js +5 -7
  85. package/dist/types/lib/capture/freeze.d.ts +47 -0
  86. package/dist/types/lib/capture/index.d.ts +1 -0
  87. package/dist/types/lib/detach/drivers/immediate.d.ts +25 -24
  88. package/dist/types/lib/detach/drivers/microtaskBatch.d.ts +5 -4
  89. package/dist/types/lib/memory/EventLog.d.ts +28 -3
  90. package/dist/types/lib/memory/TransactionBuffer.d.ts +2 -2
  91. package/dist/types/lib/memory/backtrack.d.ts +24 -0
  92. package/dist/types/lib/memory/commitLogUtils.d.ts +129 -38
  93. package/dist/types/lib/memory/deltaEncoding.d.ts +1 -1
  94. package/dist/types/lib/memory/honesty.d.ts +5 -1
  95. package/dist/types/lib/memory/index.d.ts +2 -2
  96. package/dist/types/lib/memory/keyPaths.d.ts +125 -0
  97. package/dist/types/lib/memory/logModel.d.ts +76 -0
  98. package/dist/types/lib/memory/placeholders.d.ts +3 -3
  99. package/dist/types/lib/memory/redaction.d.ts +34 -1
  100. package/dist/types/lib/memory/utils.d.ts +2 -6
  101. package/dist/types/lib/memory/verbs.d.ts +65 -22
  102. package/dist/types/lib/scope/protection/readonlyInput.d.ts +2 -6
  103. package/dist/types/lib/slice/elementProvenance.d.ts +4 -1
  104. package/dist/types/lib/slice/forwardSliceForKey.d.ts +4 -0
  105. package/dist/types/lib/slice/keyIndex.d.ts +50 -8
  106. package/dist/types/lib/slice/types.d.ts +53 -7
  107. package/dist/types/trace.d.ts +2 -1
  108. package/package.json +2 -1
package/AGENTS.md CHANGED
@@ -2,34 +2,26 @@
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
+ > Every TypeScript block in this file is type-checked (strict) and run against the built package (footprintjs 9.32.0); the `// …` lines under a `console.log` are that run's real output.
6
+
5
7
  ## Core Principle
6
8
 
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.
9
+ **Collect during traversal, never post-process.** All data collection (narrative, metrics, manifest, topology, in/out boundaries) happens as side effects of the single DFS traversal pass. Never walk the tree again after execution.
8
10
 
9
11
  ## Architecture — Library of Libraries
10
12
 
11
- ```
12
- src/lib/
13
- ├── capture/ → Value-capture/retention primitives (RetentionPolicy 'full'|'summary'|'off', read/write summary markers) — shared by the readTracking (#14) + writeTracking (#13c-A) dials; RFC-001 builds on it; also the shared leaves `invokeHook`, `circular`, `summarizeValue` (`lib/devMode.ts`, the dev-mode flag, sits beside it)
14
- ├── memory/ → Transactional state (SharedMemory, StageContext, TransactionBuffer, EventLog)
15
- ├── schema/ → Validation abstraction (Zod optional, duck-typed detection)
16
- ├── builder/ → Fluent DSL (FlowChartBuilder, flowChart(), DeciderList, SelectorFnList)
17
- ├── scope/ → Per-stage facades + recorders + providers
18
- ├── reactive/ → TypedScope<T> deep Proxy (typed property access, $-methods, cycle-safe)
19
- ├── decide/ → decide()/select() decision evidence capture (filter + function)
20
- ├── recorder/ → CompositeRecorder, KeyedStore<T>, SequenceStore<T>, BoundaryStateStore<TState>, composition primitives
21
- ├── pause/ → Pause/Resume (PauseSignal, FlowchartCheckpoint, PausableHandler)
22
- ├── engine/ → DFS traversal + narrative + 13 handlers
23
- ├── runner/ → High-level executor (FlowChartExecutor)
24
- └── contract/ → I/O schema + OpenAPI generation
25
- ```
13
+ `package.json` `exports` has six doors — import from the one that owns the symbol:
26
14
 
27
- Layering is enforced, not described: a file imports only its own layer or below. The table is `scripts/layering.config.cjs` — leaves (`capture/`, `schema/`, `pause/`, `lib/devMode.ts`) < `memory/` < `recorder/` · `scope/` · `reactive/` · `decide/` < `engine/` < `builder/` · `runner/` · `contract/` · `detach/` — checked by lint (`import/no-restricted-paths` zones + `import/no-cycle`) and `npm run check:layering` (value-level cycles + upward runtime edges). Three edges are named on purpose: builder → `runner/RunnableChart.ts`, engine → `reactive/handles.ts`, scope → `detach/spawn.ts`.
15
+ | Import | What it is for |
16
+ |---|---|
17
+ | `footprintjs` | The main door: `flowChart`, `FlowChartExecutor`, `decide` / `select`, `narrative`, the built-in recorder classes, `interrupt`, and the public types |
18
+ | `footprintjs/recorders` | Recorder factories — `narrative()`, `metrics()`, `debug()`, `manifest()`, `adaptive()`, `milestone()`, `windowed()` — and `CompositeRecorder` |
19
+ | `footprintjs/trace` | Execution tracing: runtimeStageId helpers, commit-log queries (`findCommit`, `findLastWriter`, `causalChain`, `sliceForKey`, `stateAt`, `timeTravel`), the storage primitives (`KeyedStore`, `SequenceStore`, `BoundaryStateStore`), `topologyRecorder()` / `inOutRecorder()`, `HONESTY_CODES` |
20
+ | `footprintjs/advanced` | Engine internals (`SharedMemory`, `StageContext`, `FlowchartTraverser`, `ScopeFacade`, scope providers, `SCOPE_METHOD_NAMES`, `ArrayMergeMode`) plus a small hand-picked slice of the trace helpers (`findCommit`, `findCommits`, `findLastWriter`, `parseRuntimeStageId`, `buildRuntimeStageId`) — not all of `trace` |
21
+ | `footprintjs/detach` | Fire-and-forget child charts and their drivers |
22
+ | `footprintjs/zod` | Opt-in zod bridge (`defineScopeFromZod`, …) — the core never imports zod |
28
23
 
29
- Three entry points:
30
- - `import { ... } from 'footprintjs'` — public API
31
- - `import { ... } from 'footprintjs/trace'` — execution tracing: runtimeStageId, commitLog queries, storage primitives (KeyedStore, SequenceStore, BoundaryStateStore)
32
- - `import { ... } from 'footprintjs/advanced'` — engine internals (also re-exports trace)
24
+ The module map (one line per `src/lib/` directory) and the layering rule — a file imports only its own layer or below — live in one place each, so this file keeps no copy that can drift: `CLAUDE.md` ("Module map" and "The fence", shipped with the package) and the layer table in `scripts/layering.config.cjs`, enforced by lint (`import/no-restricted-paths` zones + `import/no-cycle`) and `npm run check:layering` (value-level cycles + upward runtime edges).
33
25
 
34
26
  ## Key API
35
27
 
@@ -49,10 +41,12 @@ interface LoanState {
49
41
  const chart = flowChart<LoanState>('Intake', async (scope) => {
50
42
  scope.creditTier = 'A'; // typed write
51
43
  scope.amount = 50000; // typed write
52
- scope.customer.address.zip = '90210'; // deep write (updateValue)
44
+ scope.customer = { name: 'Ana', address: { zip: '10001' } };
45
+ scope.tags = [];
46
+ scope.customer.address.zip = '90210'; // deep write (recorded as an update of `customer`)
53
47
  scope.tags.push('vip'); // array copy-on-write (single push)
54
- scope.$batchArray('tags', (arr) => { // O(1) batch: 1 clone + 1 commit
55
- arr.push('vip', 'premium', 'verified');
48
+ scope.$batchArray('tags', (arr) => { // batch: one clone + one commit however many mutations
49
+ arr.push('premium', 'verified');
56
50
  });
57
51
  scope.approved = true; // optional field
58
52
 
@@ -61,28 +55,82 @@ const chart = flowChart<LoanState>('Intake', async (scope) => {
61
55
  scope.$metric('latency', 42);
62
56
  const args = scope.$getArgs<{ requestId: string }>();
63
57
  const env = scope.$getEnv();
64
- scope.$break(); // stop pipeline
58
+ console.log(args.requestId, env.traceId);
59
+ // req-123 trace-1
60
+ scope.$break(); // stop the run
65
61
  }, 'intake')
66
62
  .build();
67
63
 
68
64
  const executor = new FlowChartExecutor(chart);
69
- await executor.run({ input: { requestId: 'req-123' } });
65
+ executor.enableNarrative();
66
+ await executor.run({ input: { requestId: 'req-123' }, env: { traceId: 'trace-1' } });
67
+
68
+ console.log(JSON.stringify(executor.getSnapshot().sharedState));
69
+ // {"creditTier":"A","amount":50000,"customer":{"name":"Ana","address":{"zip":"90210"}},"tags":["vip","premium","verified"],"approved":true}
70
+ // the batch is ONE write; the single push before it is another
71
+ console.log(JSON.stringify(executor.getNarrativeEntries().filter((e) => e.text.includes('Write tags')).map((e) => e.text)));
72
+ // ["Step 4: Write tags = []","Step 8: Write tags = (1 item)","Step 10: Write tags = (3 items)"]
70
73
  ```
71
74
 
72
75
  ### decide() / select() — Decision Evidence Capture
73
76
 
74
77
  ```typescript
75
- import { decide, select } from 'footprintjs';
78
+ import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
79
+
80
+ interface RiskState {
81
+ creditScore: number;
82
+ dti: number;
83
+ }
84
+
85
+ const chart = flowChart<RiskState>('Intake', (scope) => {
86
+ scope.creditScore = 750;
87
+ scope.dti = 0.35;
88
+ }, 'intake')
89
+ // Inside a decider function — auto-captures which values led to the decision
90
+ .addDeciderFunction('ClassifyRisk', (scope) => {
91
+ return decide(scope, [
92
+ { when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'approved', label: 'Good credit' },
93
+ { when: (s) => s.creditScore > 600, then: 'manual-review', label: 'Marginal' },
94
+ ], 'rejected');
95
+ }, 'classify-risk')
96
+ .addFunctionBranch('approved', 'Approve', () => {})
97
+ .addFunctionBranch('manual-review', 'Review', () => {})
98
+ .addFunctionBranch('rejected', 'Reject', () => {})
99
+ .end()
100
+ .build();
101
+
102
+ const executor = new FlowChartExecutor(chart);
103
+ executor.enableNarrative();
104
+ await executor.run();
105
+ console.log(executor.getNarrativeEntries().find((e) => e.type === 'condition')?.text);
106
+ // [Condition]: It evaluated Rule 0 "Good credit": creditScore 750 gt 700 ✓, dti 0.35 lt 0.43 ✓, and chose Approve.
107
+ ```
108
+
109
+ Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`. `decide()` returns `{ branch, evidence }`; the narrative names the chosen branch by its **name** (`Approve`), the evidence by its **id** (`approved`).
76
110
 
77
- // Inside a decider function — auto-captures which values led to the decision
78
- .addDeciderFunction('ClassifyRisk', (scope) => {
79
- return decide(scope, [
80
- { when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'approved', label: 'Good credit' },
81
- { when: (s) => s.creditScore > 600, then: 'manual-review', label: 'Marginal' },
82
- ], 'rejected');
83
- }, 'classify-risk')
111
+ `select()` is the multi-pick twin — every matching rule picks its branch (not first-match), and the picked branches run in parallel:
112
+
113
+ ```typescript
114
+ import { flowChart, FlowChartExecutor, select } from 'footprintjs';
115
+
116
+ const chart = flowChart<{ glucose: number; bmi: number }>('Intake', (scope) => {
117
+ scope.glucose = 120;
118
+ scope.bmi = 31;
119
+ }, 'intake')
120
+ .addSelectorFunction('Screen', (scope) => select(scope, [
121
+ { when: { glucose: { gt: 100 } }, then: 'diabetes', label: 'High glucose' },
122
+ { when: { bmi: { gt: 30 } }, then: 'obesity', label: 'High BMI' },
123
+ ]), 'screen')
124
+ .addFunctionBranch('diabetes', 'DiabetesPath', () => {})
125
+ .addFunctionBranch('obesity', 'ObesityPath', () => {})
126
+ .end()
127
+ .build();
84
128
 
85
- // Narrative: "It evaluated Rule 0 'Good credit': creditScore 750 gt 700, and chose approved."
129
+ const executor = new FlowChartExecutor(chart);
130
+ executor.enableNarrative();
131
+ await executor.run();
132
+ console.log(executor.getNarrativeEntries().find((e) => e.type === 'fork')?.text);
133
+ // [Parallel]: Forking into 2 parallel paths: DiabetesPath, ObesityPath.
86
134
  ```
87
135
 
88
136
  **Naming the default branch.** The default is chosen by no rule, so no
@@ -92,132 +140,264 @@ string; the label lands on `DecisionEvidence.defaultLabel` and is recorded on
92
140
  every decision, not just the runs that fell through.
93
141
 
94
142
  ```typescript
95
- decide(scope, rules, { branch: 'rejected', label: 'No rule fired — application rejected' });
96
- // bare string still works, byte-identical: decide(scope, rules, 'rejected')
143
+ import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
144
+
145
+ for (const creditScore of [750, 100]) {
146
+ const chart = flowChart<{ creditScore: number }>('Intake', (scope) => { scope.creditScore = creditScore; }, 'intake')
147
+ .addDeciderFunction('ClassifyRisk', (scope) => decide(scope, [
148
+ { when: { creditScore: { gt: 700 } }, then: 'approved', label: 'Good credit' },
149
+ ], { branch: 'rejected', label: 'No rule fired — application rejected' }), 'classify-risk')
150
+ .addFunctionBranch('approved', 'Approve', () => {})
151
+ .addFunctionBranch('rejected', 'Reject', () => {})
152
+ .end()
153
+ .build();
154
+ const executor = new FlowChartExecutor(chart);
155
+ executor.attachFlowRecorder({
156
+ id: 'evidence',
157
+ onDecision: (e) => console.log(e.evidence?.chosen, '|', e.evidence?.defaultLabel),
158
+ });
159
+ await executor.run();
160
+ }
161
+ // approved | No rule fired — application rejected
162
+ // rejected | No rule fired — application rejected
163
+ // a bare string default still works: decide(scope, rules, 'rejected') — the evidence then has no `defaultLabel` key
97
164
  ```
98
165
 
99
166
  ### Builder
100
167
 
101
168
  ```typescript
102
- import { flowChart, FlowChartBuilder } from 'footprintjs';
169
+ import { flowChart } from 'footprintjs';
103
170
 
104
- const chart = flowChart('Stage1', fn1, 'stage-1', undefined, 'Description')
105
- .addFunction('Stage2', fn2, 'stage-2', 'Description')
106
- .addDeciderFunction('Decide', deciderFn, 'decide', 'Route based on risk')
107
- .addFunctionBranch('high', 'Reject', rejectFn)
108
- .addFunctionBranch('low', 'Approve', approveFn)
171
+ const chart = flowChart('Stage1', () => {}, 'stage-1', { description: 'Description' })
172
+ .addFunction('Stage2', () => {}, 'stage-2', 'Description')
173
+ .addDeciderFunction('Decide', () => 'high', 'decide', 'Route based on risk')
174
+ .addFunctionBranch('high', 'Reject', () => {})
175
+ .addFunctionBranch('low', 'Approve', () => {})
109
176
  .setDefault('high')
110
177
  .end()
111
178
  .build();
112
179
  ```
113
180
 
114
- Methods: `start()`, `addFunction()`, `addStreamingFunction()`, `addDeciderFunction()`, `addSelectorFunction()`, `addListOfFunction()`, `addPausableFunction()`, `addSubFlowChart()`, `addSubFlowChartNext()`, `loopTo()`, `contract()`, `build()`, `toSpec()`, `toMermaid()`
181
+ The first stage is `flowChart(name, fn, id, { description?, structureRecorders? })`; every later stage is `.addFunction(name, fn, id, description?)`.
182
+
183
+ Methods (not exhaustive): `start()`, `addFunction()`, `addStreamingFunction()`, `addDeciderFunction()`, `addSelectorFunction()`, `addListOfFunction()`, `addParallelForEach()`, `addPausableFunction()`, `addSubFlowChart()`, `addSubFlowChartNext()`, `addLazySubFlowChart()`, `addDetachAndForget()`, `addDetachAndJoinLater()`, `retry()`, `tag()`, `loopTo()`, `contract()`, `attachStructureRecorder()`, `build()`, `toSpec()`, `toMermaid()`. `toOpenAPI()` and `toMCPTool()` are on the built chart.
115
184
 
116
185
  ### ScopeFacade (Internal — use TypedScope for new code)
117
186
 
187
+ `ScopeFacade` (from `footprintjs/advanced`) is the engine's own scope; `TypedScope` wraps it. A stage only receives a `ScopeFacade` when you supply a custom `scopeFactory`. In a TypedScope stage the same operations are `scope.$getValue(key)` / `scope.$setValue(key, value)` / `scope.$getArgs()` / `scope.$getEnv()` — plain `scope.getValue` does not exist there.
188
+
118
189
  ```typescript
119
- scope.getValue('key') // tracked read
120
- scope.setValue('key', value) // tracked write
121
- scope.getArgs<T>() // frozen readonly input (NOT tracked)
122
- scope.getEnv() // frozen execution environment (NOT tracked)
190
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
191
+ import { ScopeFacade } from 'footprintjs/advanced';
192
+
193
+ // TypedScope stage: dynamic keys go through the $ methods
194
+ const typed = flowChart<Record<string, unknown>>('Typed', (scope) => {
195
+ scope.$setValue('dyn', 41);
196
+ scope.$setValue('dyn', (scope.$getValue('dyn') as number) + 1);
197
+ }, 'typed').build();
198
+ const typedRun = new FlowChartExecutor(typed);
199
+ await typedRun.run();
200
+ console.log(JSON.stringify(typedRun.getSnapshot().sharedState));
201
+ // {"dyn":42}
202
+
203
+ // ScopeFacade stage: getValue / setValue / getArgs / getEnv
204
+ const facade = flowChart<any>('Facade', (scope: any) => {
205
+ const s = scope as ScopeFacade;
206
+ s.setValue('key', { v: 1 });
207
+ console.log(JSON.stringify([s.getValue('key'), s.getArgs(), s.getEnv()]));
208
+ }, 'facade').build();
209
+ const facadeRun = new FlowChartExecutor(facade, {
210
+ scopeFactory: (ctx, stageName, readOnly, env) => new ScopeFacade(ctx, stageName, readOnly, env),
211
+ });
212
+ await facadeRun.run({ input: { requestId: 'req-9' }, env: { traceId: 'trace-9' } });
213
+ // [{"v":1},{"requestId":"req-9"},{"traceId":"trace-9"}]
123
214
  ```
124
215
 
125
216
  **Three access tiers:**
126
- - `getValue`/`setValue` — mutable shared state, tracked in narrative
127
- - `getArgs()` — frozen business input from `run({ input })`, NOT tracked
128
- - `getEnv()` — frozen infrastructure context from `run({ env })`, NOT tracked. Returns `ExecutionEnv { signal?, timeoutMs?, traceId? }`. Auto-inherited by subflows. Closed type.
217
+ - `scope.amount = 50000` (ScopeFacade: `getValue`/`setValue`) — mutable shared state, tracked in the narrative
218
+ - `$getArgs()` (ScopeFacade: `getArgs()`) — frozen business input from `run({ input })`, NOT tracked. Its keys are read-only for the run: a stage that writes a state key with the same name throws `Cannot write to readonly input key "requestId"`. A subflow's `inputMapper` result is its input and follows the same rule
219
+ - `$getEnv()` (ScopeFacade: `getEnv()`) — frozen infrastructure context from `run({ env })`, NOT tracked. Returns `ExecutionEnv { signal?, timeoutMs?, traceId? }`. Auto-inherited by subflows. Closed type
129
220
 
130
221
  ### Executor
131
222
 
132
223
  ```typescript
133
- const executor = new FlowChartExecutor(chart);
134
- // With options (preferred over positional params):
135
- const executor = new FlowChartExecutor(chart, { scopeFactory: myFactory });
136
- await executor.run({ input: data, env: { traceId: 'req-123' } });
137
-
138
- executor.attachScopeRecorder(recorder) // plug scope observer
139
- executor.getNarrativeEntries() // combined flow + data narrative
140
- executor.getNarrativeEntries() // structured entries with type/depth/stageName/stageId
141
- executor.getNarrativeEntries() // flow-only (no data ops)
142
- executor.getSnapshot() // full memory state (includes recorder snapshots)
143
- executor.attachFlowRecorder(r) // plug flow observer
144
- executor.setRedactionPolicy({}) // PII protection
145
-
146
- // Pause/Resume — human-in-the-loop
147
- executor.isPaused() // true if last run paused
148
- executor.getCheckpoint() // JSON-safe checkpoint (store in Redis/Postgres/etc.)
149
- executor.resume(checkpoint, input) // continue from checkpoint with human's answer
224
+ import { flowChart, FlowChartExecutor, MetricRecorder, NarrativeFlowRecorder } from 'footprintjs';
225
+
226
+ const chart = flowChart<{ n: number }>('One', (scope) => { scope.n = scope.$getArgs<{ x: number }>().x; }, 'one')
227
+ .addFunction('Two', (scope) => { scope.n = scope.n + 1; }, 'two')
228
+ .build();
229
+
230
+ // With options (preferred over positional params): scopeFactory, readTracking, writeTracking, commitValues, …
231
+ const executor = new FlowChartExecutor(chart, { readTracking: 'full' });
232
+ executor.enableNarrative(); // before run() — the narrative is off by default
233
+ executor.attachScopeRecorder(new MetricRecorder()); // plug scope observer
234
+ executor.attachFlowRecorder(new NarrativeFlowRecorder()); // plug flow observer
235
+ executor.setRedactionPolicy({ keys: ['secret'] }); // PII protection
236
+
237
+ await executor.run({ input: { x: 1 }, env: { traceId: 'req-123' } });
238
+
239
+ // CombinedNarrativeEntry[] — combined flow + data narrative
240
+ console.log(executor.getNarrativeEntries().length);
241
+ // 5
242
+ // flow-only (no data ops): drop the read/write steps
243
+ console.log(JSON.stringify(executor.getNarrativeEntries().filter((e) => e.type !== 'step').map((e) => e.text)));
244
+ // ["Stage 1: The process began with One.","Stage 2: Next, it moved on to Two."]
245
+ // full memory state (includes recorder snapshots)
246
+ console.log(JSON.stringify(Object.keys(executor.getSnapshot())));
247
+ // ["sharedState","executionTree","initialState","commitLog","commitValues","writeProvenance","runId","recorders"]
248
+
249
+ // Pause/Resume — human-in-the-loop (next section)
250
+ // executor.isPaused() // true if last run paused
251
+ // executor.getCheckpoint() // JSON-safe checkpoint (store in Redis/Postgres/etc.)
252
+ // executor.resume(checkpoint, input) // continue from checkpoint with human's answer
150
253
  ```
151
254
 
255
+ `getNarrativeEntries()` returns entries (`type`, `text`, `depth`, `stageName`, `stageId`, …), not strings: `.map((e) => e.text)` for plain lines. One executor runs one execution at a time — create one per concurrent run.
256
+
152
257
  ### Pause/Resume (Human-in-the-Loop)
153
258
 
154
259
  ```typescript
155
260
  import { flowChart, FlowChartExecutor } from 'footprintjs';
156
261
  import type { PausableHandler } from 'footprintjs';
157
262
 
263
+ interface MyState {
264
+ amount: number;
265
+ approved?: boolean;
266
+ done?: boolean;
267
+ }
268
+
158
269
  const handler: PausableHandler<MyState> = {
159
270
  execute: async (scope) => {
160
271
  // Return data = pause. Return nothing = continue.
161
272
  return { question: `Approve $${scope.amount}?` };
162
273
  },
163
274
  resume: async (scope, input) => {
164
- scope.approved = input.approved;
275
+ scope.approved = (input as { approved: boolean }).approved;
165
276
  },
166
277
  };
167
278
 
168
279
  // Pausable root stage (single-stage subflows):
169
- const chart = flowChart<MyState>('Approve', handler, 'approve').build();
280
+ const single = flowChart<MyState>('Approve', handler, 'approve').build();
170
281
 
171
282
  // Or chained after other stages:
172
- const chart2 = flowChart<MyState>('Seed', seedFn, 'seed')
283
+ const chart = flowChart<MyState>('Seed', (scope) => { scope.amount = 500; }, 'seed')
173
284
  .addPausableFunction('Approve', handler, 'approve')
174
- .addFunction('Process', processFn, 'process')
285
+ .addFunction('Process', (scope) => { scope.done = scope.approved === true; }, 'process')
175
286
  .build();
176
287
 
177
288
  const executor = new FlowChartExecutor(chart);
289
+ executor.enableNarrative();
178
290
  await executor.run();
291
+ console.log(executor.isPaused());
292
+ // true
179
293
 
180
294
  if (executor.isPaused()) {
181
- const checkpoint = executor.getCheckpoint(); // JSON-safe, store anywhere
295
+ const checkpoint = executor.getCheckpoint()!; // JSON-safe, store anywhere
296
+ console.log(checkpoint.pausedStageId, JSON.stringify(checkpoint.pauseData));
297
+ // approve {"question":"Approve $500?"}
298
+ const stored = JSON.stringify(checkpoint);
182
299
  // Later (hours, different server):
183
- await executor.resume(checkpoint, { approved: true });
300
+ await executor.resume(JSON.parse(stored), { approved: true });
184
301
  }
302
+ console.log(executor.isPaused(), JSON.stringify(executor.getSnapshot().sharedState));
303
+ // false {"amount":500,"approved":true,"done":true}
304
+ console.log(executor.getNarrativeEntries().filter((e) => e.type === 'pause' || e.type === 'resume').map((e) => e.text));
305
+ // [
306
+ // 'Execution paused at Approve.',
307
+ // 'Execution resumed at Approve with input.'
308
+ // ]
309
+ void single;
185
310
  ```
186
311
 
187
312
  - `execute` returns data → pauses. Returns void → continues normally (conditional pause).
188
- - Checkpoint is JSON-serializable — no functions, no class instances.
189
- - `resume()` reuses the execution runtime — narrative, metrics, execution tree all accumulate.
190
- - A resume re-enters ONCE, then the run is the chart as built: a `loopTo` back to the paused stage runs that stage again (it pauses again — a re-ask), a loop reaches its head even from inside a subflow, and the next pass through a subflow mount is a fresh entry (its inputMapper runs). A pause several subflows deep does not re-run the outer subflows' earlier stages, and a subflow mounted as a decider/selector branch or a fork child hands back to its parent's continuation (the decider's `next`, the fork's join). (Since 9.28.0.)
313
+ - Checkpoint is JSON-serializable — no functions, no class instances. A fresh executor resumes it too (`new FlowChartExecutor(chart).resume(JSON.parse(stored), input)`).
314
+ - `resume()` on the SAME executor reuses its runtime — narrative, metrics, execution tree all accumulate; a fresh executor seeds a new runtime from `checkpoint.sharedState`.
315
+ - A resume re-enters ONCE, then the run is the chart as built: a `loopTo` back to the paused stage runs that stage again (it pauses again — a re-ask), and the next pass through a subflow mount is a fresh entry (its inputMapper runs). A pause several subflows deep does not re-run the outer subflows' earlier stages, and a subflow mounted as a decider/selector branch or a fork child hands back to its parent's continuation (the decider's `next`, the fork's join). (Since 9.28.0.)
191
316
  - Two parallel siblings that pause in one fan-out are asked IN TURN: the checkpoint asks the first and carries the others in `checkpoint.pendingPauses`; each resume answers one; the join runs after the last. (Since 9.28.0.)
192
- - Not resumable yet: a pause inside a LAZY subflow or an `addParallelForEach` branch — `resume()` refuses it. A subflow id mounted twice in one chart — `resume()` refuses a pause inside it (give each mount its own id).
193
- - `FlowRecorder.onPause`/`onResume` and `Recorder.onPause`/`onResume` fire on both observer systems.
317
+ - Not resumable yet: a pause inside a LAZY subflow or an `addParallelForEach` branch — `resume()` rejects (`Cannot resume: stage 'lz/sf-ask' not found in flowchart`). A subflow id mounted twice in one chart — `resume()` refuses a pause inside it (give each mount its own id).
318
+ - `FlowRecorder.onPause`/`onResume` and `ScopeRecorder.onPause`/`onResume` fire on both observer systems.
194
319
 
195
320
  ### ComposableRunner & Snapshot Navigation
196
321
 
322
+ `ComposableRunner<TIn, TOut>` is the interface for a runner that exposes its internal chart so a parent can mount it as a subflow: `toFlowChart()` and `run(input, options?)`.
323
+
197
324
  ```typescript
325
+ import { flowChart, FlowChartExecutor, getSubtreeSnapshot, listSubflowPaths } from 'footprintjs';
198
326
  import type { ComposableRunner } from 'footprintjs';
199
- import { getSubtreeSnapshot, listSubflowPaths } from 'footprintjs';
200
327
 
201
- const subtree = getSubtreeSnapshot(snapshot, 'sf-payment');
202
- listSubflowPaths(snapshot); // ['sf-payment', 'sf-outer/sf-inner']
328
+ class Doubler implements ComposableRunner<number, number> {
329
+ toFlowChart() {
330
+ return flowChart<{ out?: number }>('Double', (scope) => { scope.out = scope.$getArgs<{ n: number }>().n * 2; }, 'double').build();
331
+ }
332
+ async run(input: number): Promise<number> {
333
+ const executor = new FlowChartExecutor(this.toFlowChart());
334
+ await executor.run({ input: { n: input } });
335
+ return (executor.getSnapshot().sharedState as { out: number }).out;
336
+ }
337
+ }
338
+
339
+ const outer = flowChart<{ out?: number }>('Outer', () => {}, 'outer')
340
+ .addSubFlowChartNext('sf-doubler', new Doubler().toFlowChart(), 'Doubler', {
341
+ inputMapper: () => ({ n: 21 }),
342
+ outputMapper: (sub: { out?: number }) => ({ out: sub.out }),
343
+ })
344
+ .build();
345
+ const root = flowChart<{ out?: number }>('Root', () => {}, 'root')
346
+ .addSubFlowChartNext('sf-outer', outer, 'OuterMount', { outputMapper: (sub: { out?: number }) => ({ out: sub.out }) })
347
+ .build();
348
+
349
+ const executor = new FlowChartExecutor(root);
350
+ await executor.run();
351
+ const snapshot = executor.getSnapshot();
352
+
353
+ console.log(await new Doubler().run(4));
354
+ // 8
355
+ console.log(listSubflowPaths(snapshot));
356
+ // [ 'sf-outer/sf-doubler', 'sf-outer' ]
357
+ const subtree = getSubtreeSnapshot(snapshot, 'sf-outer/sf-doubler');
358
+ console.log(Object.keys(subtree ?? {}), JSON.stringify(subtree?.sharedState));
359
+ // [
360
+ // 'subflowId',
361
+ // 'executionTree',
362
+ // 'sharedState',
363
+ // 'history',
364
+ // 'initialState',
365
+ // 'narrativeEntries'
366
+ // ] {"n":21,"out":42}
203
367
  ```
204
368
 
205
369
  ## Observer Channels
206
370
 
207
- Four pluggable observer channels — three fire at runtime (Scope, Flow, Emit) and one fires at build time (Structure). All share `{ id, hooks } -> dispatcher -> error isolation -> attach/detach`. A recorder is routed to channels by runtime duck-typing of its `on*` methods. Intentionally NOT unified into one interface — each channel has a distinct invariant set.
371
+ Four pluggable observer channels — three fire at runtime (Scope, Flow, Emit) and one fires at build time (Structure). The runtime channels share `{ id, hooks } -> dispatcher -> error isolation -> attach/detach` (the Structure channel has `attachStructureRecorder` only). `attachCombinedRecorder(r)` routes a recorder to channels by runtime duck-typing of its `on*` methods. Intentionally NOT unified into one interface — each channel has a distinct invariant set.
208
372
 
209
373
  **Recorder ID contract:**
210
- - `attachRecorder` is **idempotent by ID** — same ID replaces, different IDs coexist. Prevents accidental double-counting.
374
+ - `attachScopeRecorder` / `attachFlowRecorder` / `attachEmitRecorder` / `attachCombinedRecorder` are **idempotent by ID** — same ID replaces, different IDs coexist. Prevents accidental double-counting.
211
375
  - Built-in recorders use auto-increment default IDs (`metrics-1`, `debug-1`, ...) so multiple instances with different configs coexist naturally.
212
376
  - 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).
213
377
 
214
- **Scope Recorder** (data ops — fires DURING stage execution):
215
- - `onRead`, `onWrite`, `onCommit`, `onError`, `onStageStart`, `onStageEnd`
378
+ ```typescript
379
+ import { flowChart, FlowChartExecutor, MetricRecorder, DebugRecorder } from 'footprintjs';
380
+
381
+ console.log(new MetricRecorder().id, new MetricRecorder().id, new MetricRecorder('metrics').id, new DebugRecorder({ id: 'dbg' }).id);
382
+ // metrics-1 metrics-2 metrics dbg
383
+
384
+ const executor = new FlowChartExecutor(flowChart<{ a: number }>('One', (scope) => { scope.a = 1; }, 'one').build());
385
+ let first = 0;
386
+ let second = 0;
387
+ executor.attachScopeRecorder({ id: 'same', onWrite: () => { first++; } });
388
+ executor.attachScopeRecorder({ id: 'same', onWrite: () => { second++; } }); // same id: replaces the first
389
+ await executor.run();
390
+ console.log(first, second);
391
+ // 0 1
392
+ ```
393
+
394
+ **Scope Recorder** (data ops — `onStageStart` → `onRead`/`onWrite` DURING the stage function → `onStageEnd` → `onCommit`):
395
+ - `onStageStart`, `onRead`, `onWrite`, `onStageEnd`, `onCommit`, `onError`, `onPause`, `onResume`
216
396
  - Built-in: `MetricRecorder`, `DebugRecorder`
217
397
 
218
- **FlowRecorder** (control flow — fires AFTER stage execution):
219
- - `onStageExecuted` (universal "did this stage run", carries `stageType: 'linear' | 'decider' | 'fork' | 'selector' | 'subflow-mount'`), `onNext`, `onDecision`, `onFork`, `onSelected`, `onSubflowEntry/Exit`, `onSubflowRegistered`, `onLoop`, `onBreak`, `onError`, `onPause`/`onResume`, `onRunStart`/`onRunEnd`, `onRunFailed`
220
- - All events carry `traversalContext: TraversalContext` (includes per-run `runId`)
398
+ **FlowRecorder** (control flow — fires AFTER the stage's commit):
399
+ - `onStageExecuted` (universal "did this stage run", carries `stageType: 'linear' | 'decider' | 'fork' | 'selector' | 'subflow-mount'`), `onNext`, `onDecision`, `onFork`, `onSelected`, `onSubflowEntry/Exit`, `onSubflowRegistered`, `onLoop`, `onBreak`, `onStageRetry`, `onError`, `onPause`/`onResume`, `onRunStart`/`onRunEnd`, `onRunFailed`
400
+ - Events carry an optional `traversalContext: TraversalContext` (includes per-run `runId`)
221
401
  - `onDecision`/`onSelected` carry optional `evidence` from decide()/select()
222
402
  - Built-in: 9 strategies (Narrative, Adaptive, Windowed, RLE, Milestone, Progressive, Separate, Manifest, Silent)
223
403
 
@@ -225,76 +405,154 @@ Four pluggable observer channels — three fire at runtime (Scope, Flow, Emit) a
225
405
  - `onEmit(EmitEvent)` — see the "Emit Channel" section below.
226
406
 
227
407
  **Structure Recorder** (build-time chart shape — fires SYNCHRONOUSLY during builder operations, NOT runtime):
228
- - `onStageAdded`, `onEdgeAdded`, `onLoopEdgeAdded`, `onDeciderComplete`, `onSubflowMounted`
229
- - Attach via options bag — `flowChart('seed', fn, 'seed', { structureRecorders: [rec] })` — or fluent `.attachStructureRecorder(rec)`. MOUNT-ONLY: a builder's recorder sees only that builder's events; subflow internals arrive via the mount event's `subflowSpec` (walk with `walkSubflowSpec` from `footprintjs/trace`). Exported from the main `footprintjs` barrel.
408
+ - `onStageAdded`, `onStageTagged`, `onEdgeAdded`, `onLoopEdgeAdded`, `onDeciderComplete`, `onSubflowMounted`
409
+ - Attach via options bag — `flowChart('seed', fn, 'seed', { structureRecorders: [rec] })` — or fluent `.attachStructureRecorder(rec)`. MOUNT-ONLY: a builder's recorder sees only that builder's events; subflow internals arrive via the mount event's `subflowSpec` (walk with `walkSubflowSpec` from `footprintjs/trace`). The types are exported from the main `footprintjs` barrel.
410
+
411
+ ```typescript
412
+ import { flowChart } from 'footprintjs';
413
+ import type { StructureRecorder, StructureSubflowMountedEvent } from 'footprintjs';
414
+ import { walkSubflowSpec } from 'footprintjs/trace';
415
+
416
+ const events: string[] = [];
417
+ let mounted: StructureSubflowMountedEvent | undefined;
418
+ const recorder: StructureRecorder = {
419
+ id: 'structure',
420
+ onStageAdded: (e) => { events.push(`stage ${e.spec.id}`); },
421
+ onEdgeAdded: (e) => { events.push(`edge ${e.from} -> ${e.to} (${e.kind})`); },
422
+ onSubflowMounted: (e) => { events.push(`mounted ${e.subflowId}`); mounted = e; },
423
+ };
424
+
425
+ const sub = flowChart<object>('SubA', () => {}, 'sub-a').addFunction('SubB', () => {}, 'sub-b').build();
426
+ flowChart<object>('Seed', () => {}, 'seed', { structureRecorders: [recorder] })
427
+ .addSubFlowChartNext('sf', sub, 'Sub')
428
+ .build();
429
+
430
+ console.log(events); // the subflow's own stages (sub-a, sub-b) are NOT reported here
431
+ // [ 'stage seed', 'stage sf', 'edge seed -> sf (next)', 'mounted sf' ]
432
+ for (const item of walkSubflowSpec(mounted!.subflowSpec!, mounted!.subflowPath ?? mounted!.subflowId)) {
433
+ console.log(item.kind);
434
+ }
435
+ // subflow-start
436
+ // stage
437
+ // edge
438
+ // stage
439
+ ```
230
440
 
231
- **CombinedNarrativeRecorder** implements the Scope + Flow + Emit interfaces. Attached via `chart.recorder(narrative())` (fluent) or `executor.attachCombinedRecorder(narrative())` / `executor.enableNarrative()` at runtime.
441
+ **CombinedNarrativeRecorder** implements the Scope + Flow + Emit interfaces. `executor.enableNarrative()` installs it (read it with `executor.getNarrativeEntries()`); `chart.recorder(narrative())` attaches a `narrative()` instance for a one-shot run, and `executor.attachCombinedRecorder(narrative())` works too — read either back from the instance (`recorder.getEntries()`); the executor's own `getNarrativeEntries()` belongs to `enableNarrative()`. It is not exported as a class.
232
442
 
233
443
  ## Event Ordering
234
444
 
235
445
  ```
236
- 0. FlowRecorder.onRunStart — once per executor.run(), carries the run input
237
- 1. Recorder.onStageStart — stage begins
238
- 2. Recorder.onRead/onWrite — DURING execution (buffered per-stage)
239
- 3. Recorder.onCommit — transaction flush
240
- 4. Recorder.onStageEnd — stage completes
241
- 5. FlowRecorder.onStageExecuted — universal "stage ran" (stageType discriminator); CombinedNarrativeRecorder flushes buffered ops for LINEAR stages here
242
- 6. FlowRecorder.onNext/onDecision/onFork/onSelected — control flow continues (non-linear onStageExecuted fires AFTER its specialized event)
243
- 7. FlowRecorder.onRunEnd (clean) or onRunFailed (error) — once per run, closes the boundary symmetrically
446
+ 0. FlowRecorder.onRunStart — once per executor.run(), before any stage; event.payload = the run input
447
+ Per stage, ScopeRecorder events:
448
+ 1. Recorder.onStageStart — stage begins
449
+ 2. Recorder.onRead/onWrite — DURING execution, before the commit
450
+ 3. Recorder.onStageEnd — the stage function returned
451
+ 4. Recorder.onCommit — transaction flush
452
+ Then FlowRecorder events, by stage kind:
453
+ 5. linear stage onStageExecuted → onNext
454
+ decider onDecision → onStageExecuted
455
+ selector onSelected → onStageExecuted → onFork
456
+ fork parent onStageExecuted → onFork → onStageExecuted (stageType 'fork')
457
+ subflow mount onNext → onSubflowEntry → onStageExecuted (stageType 'subflow-mount')
458
+ → the subflow's own stages → onSubflowExit
459
+ 6. FlowRecorder.onRunEnd (clean; event.payload = the chart's return value) or onRunFailed (error; event.structuredError) — once per run, closes the boundary symmetrically
244
460
  ```
245
461
 
462
+ `CombinedNarrativeRecorder` flushes a stage's buffered reads and writes when that stage's flow event arrives: `onStageExecuted` for LINEAR stages, `onDecision` / `onSelected` / `onFork` / `onSubflowEntry` for the others.
463
+
246
464
  ## Execution Tracing (`footprintjs/trace`)
247
465
 
248
466
  Every stage execution gets a unique `runtimeStageId` — the universal key that links recorder events, commit log entries, and execution tree nodes.
249
467
 
250
468
  **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).
251
469
 
252
- **Format:** `[subflowPath/]stageId#executionIndex`
470
+ **Format:** `[subflowPath/]stageId#executionIndex` — the index starts at 0 and counts every stage execution across the run (a loop revisits the same stageId with a higher index):
253
471
 
254
472
  ```
255
- seed#0 — root stage
256
- call-llm#5 — 5th execution step
257
- sf-tools/execute-tool-calls#8 — subflow stage
258
- call-llm#9 — same stageId, different execution (loop)
473
+ seed#0 — root stage, the first execution
474
+ tick#6 — the seventh execution
475
+ sf-outer/sf-inner/inner-end#5 — a stage inside a nested subflow
476
+ tick#9 — same stageId, different execution (loop)
259
477
  ```
260
478
 
261
- **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`.
479
+ **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`. A subflow mount appears twice in the root log under one runtimeStageId; the subflow's own stage commits are in its subtree history (`getSubtreeSnapshot(snapshot, path).history`).
262
480
 
263
481
  ```typescript
264
- import { parseRuntimeStageId, findLastWriter, findCommit } from 'footprintjs/trace';
482
+ import { flowChart, FlowChartExecutor, decide, getSubtreeSnapshot } from 'footprintjs';
483
+ import { parseRuntimeStageId, buildRuntimeStageId, splitStageId, findLastWriter, findCommit, findCommits, isCommitBundle } from 'footprintjs/trace';
265
484
 
266
- // Parse a runtimeStageId into components
267
- parseRuntimeStageId('sf-tools/execute-tool-calls#8');
268
- // → { stageId: 'execute-tool-calls', executionIndex: 8, subflowPath: 'sf-tools' }
485
+ const inner = flowChart<{ v: number; t?: number; out?: number }>('InnerStart', (scope) => { scope.t = scope.v + 1; }, 'inner-start')
486
+ .addFunction('InnerEnd', (scope) => { scope.out = (scope.t ?? 0) * 2; }, 'inner-end')
487
+ .build();
488
+ const outer = flowChart<{ n: number; w?: number; out?: number }>('OuterStart', (scope) => { scope.w = scope.n; }, 'outer-start')
489
+ .addSubFlowChartNext('sf-inner', inner, 'Inner', {
490
+ inputMapper: (parent: { w?: number }) => ({ v: parent.w }),
491
+ outputMapper: (sub: { out?: number }) => ({ out: sub.out }),
492
+ })
493
+ .build();
494
+ const chart = flowChart<{ n: number; count: number }>('Seed', (scope) => { scope.n = 1; scope.count = 0; }, 'seed')
495
+ .addSubFlowChartNext('sf-outer', outer, 'Outer', {
496
+ inputMapper: (parent: { n: number }) => ({ n: parent.n }),
497
+ outputMapper: (sub: { out?: number }) => ({ out: sub.out }),
498
+ })
499
+ .addFunction('Tick', (scope) => { scope.count = scope.count + 1; }, 'tick')
500
+ .addDeciderFunction('Route', (scope) => decide(scope, [
501
+ { when: { count: { lt: 2 } }, then: 'again', label: 'under 2' },
502
+ ], 'done'), 'route')
503
+ .addFunctionBranch('again', 'Again', () => {})
504
+ .loopTo('tick')
505
+ .addFunctionBranch('done', 'Done', () => {})
506
+ .end()
507
+ .build();
508
+
509
+ const executor = new FlowChartExecutor(chart);
510
+ await executor.run();
269
511
 
270
512
  // Get the commit log after execution
271
513
  const snapshot = executor.getSnapshot();
272
514
  const commitLog = snapshot.commitLog; // CommitBundle[]
515
+ console.log(commitLog.map((bundle) => bundle.runtimeStageId).join(' '));
516
+ // seed#0 sf-outer#1 sf-outer#1 tick#6 route#7 again#8 tick#9 route#10 done#11
517
+
518
+ // A subflow's own stage commits live in its subtree history, not in the root log
519
+ const innerHistory = (getSubtreeSnapshot(snapshot, 'sf-outer/sf-inner')?.history ?? []).filter(isCommitBundle);
520
+ console.log(innerHistory.map((bundle) => bundle.runtimeStageId).join(' '));
521
+ // sf-outer/sf-inner/inner-start#4 sf-outer/sf-inner/inner-end#5
522
+
523
+ // Parse a runtimeStageId into components
524
+ console.log(JSON.stringify(parseRuntimeStageId(innerHistory[1].runtimeStageId)));
525
+ // {"stageId":"inner-start","executionIndex":4,"subflowPath":"sf-outer/sf-inner"}
526
+ console.log(buildRuntimeStageId('inner-end', 5, 'sf-outer/sf-inner'), JSON.stringify(splitStageId('sf-outer/sf-inner/inner-end')));
527
+ // sf-outer/sf-inner/inner-end#5 {"localStageId":"inner-end","subflowPath":"sf-outer/sf-inner"}
273
528
 
274
- // Backtrack: who last wrote 'systemPrompt' before commitLog array index 8?
529
+ // Backtrack: who last wrote 'count' before commitLog array index 6?
275
530
  // beforeIdx is the CommitBundle.idx (array position), NOT the executionIndex from runtimeStageId.
276
- const writer = findLastWriter(commitLog, 'systemPrompt', 8);
277
- // → CommitBundle | undefined (has .stage, .stageId, .runtimeStageId, .trace, .overwrite, .updates)
531
+ const writer = findLastWriter(commitLog, 'count', 6);
532
+ console.log(writer?.runtimeStageId, writer?.idx, JSON.stringify(writer?.trace));
533
+ // tick#6 3 [{"path":"count","verb":"set"}]
534
+ // → CommitBundle | undefined (has .idx, .stage, .stageId, .runtimeStageId, .trace, .overwrite, .updates, .redactedPaths)
278
535
 
279
536
  // Find by stageId: use findCommit when you know the stage.
280
537
  // Use findLastWriter when you know the key but not which stage wrote it.
281
- const llmCommit = findCommit(commitLog, 'call-llm', 'adapterRawResponse');
538
+ console.log(findCommit(commitLog, 'tick', 'count')?.runtimeStageId, findCommits(commitLog, 'tick').map((bundle) => bundle.runtimeStageId));
539
+ // tick#6 [ 'tick#6', 'tick#9' ]
282
540
  ```
283
541
 
284
- **Exports from `footprintjs/trace`:**
542
+ **Exports from `footprintjs/trace`** (the door has many more — causal chains, slices, time travel, honesty codes, `ControlDepRecorder`, `QualityRecorder`; see `src/lib/slice/README.md` and `src/lib/time-travel/README.md`):
285
543
 
286
544
  | Export | Returns | Use |
287
545
  |--------|---------|-----|
288
546
  | `buildRuntimeStageId(stageId, idx, subflowPath?)` | `string` | Construct an ID from components |
289
- | `parseRuntimeStageId(id)` | `{ stageId, executionIndex, subflowPath }` | Decompose an ID |
290
- | `findCommit(commitLog, stageId, key?)` | `CommitBundle \| undefined` | Find first commit by stageId |
547
+ | `parseRuntimeStageId(id)` | `{ stageId, executionIndex, subflowPath }` (`subflowPath` is `undefined` at top level) | Decompose an ID |
548
+ | `findCommit(commitLog, stageId, key?)` | `CommitBundle \| undefined` | Find the first commit by stageId (that wrote `key`, if given) |
291
549
  | `findCommits(commitLog, stageId)` | `CommitBundle[]` | Find all commits by stageId |
292
550
  | `findLastWriter(commitLog, key, beforeIdx?)` | `CommitBundle \| undefined` | Search backwards for who wrote a key |
293
551
  | `splitStageId(prefixedId)` | `{ localStageId, subflowPath }` | Decompose a bare prefixed id (`spec.id`, `CommitBundle.stageId`) |
294
552
  | `walkSubflowSpec(spec, subflowPath, opts?)` | `Generator<WalkerItem>` | Walk a subflow spec from `StructureSubflowMountedEvent.subflowSpec` |
295
- | `KeyedStore<T>` | class (v5 primary) | Storage shelf for 1:1 Map keyed by runtimeStageId (`set`/`get`/`aggregate`/`accumulate`/`filterByKeys`) |
296
- | `SequenceStore<T>` | class (v5 primary) | Storage shelf for 1:N ordered entries (`push`/`getByKey`/`getEntryRanges()` for O(1) time-travel/`getEntriesUpTo`) |
297
- | `BoundaryStateStore<TState>` | class (v5 primary) | Storage shelf for transient bracket-scoped state — live state DURING a `[start, stop]` interval; clears on stop. O(1) reads via `get` / `hasActive` / `activeCount`; lifecycle via `start` / `update` / `stop`. |
553
+ | `KeyedStore<T>` | class (since 5.0.0) | Storage shelf for 1:1 Map keyed by runtimeStageId (`set`/`get`/`aggregate`/`accumulate`/`filterByKeys`) |
554
+ | `SequenceStore<T>` | class (since 5.0.0) | Storage shelf for 1:N ordered entries (`push`/`getByKey`/`getEntryRanges()` for O(1) time-travel/`getEntriesUpTo`) |
555
+ | `BoundaryStateStore<TState>` | class (since 5.0.0) | Storage shelf for transient bracket-scoped state — live state DURING a `[start, stop]` interval; clears on stop. O(1) reads via `get` / `hasActive` / `activeCount`; lifecycle via `start` / `update` / `stop`. |
298
556
  | `KeyedRecorder<T>` / `SequenceRecorder<T>` / `BoundaryStateTracker<TState>` | abstract bases — **REMOVED in 7.0.0** | Gone — no inheritance path remains. Superseded by the `*Store` classes above: own a store as a field and implement the channel interface yourself. |
299
557
  | `CommitRangeIndex<TLabel>` | class | Interval index over commit indices (`open`/`close`/`enclosing`/`overlapping`) |
300
558
  | `topologyRecorder()` / `TopologyRecorder` | factory / class | Live composition graph for streaming consumers (subflow nodes + control-flow edges) |
@@ -302,7 +560,7 @@ const llmCommit = findCommit(commitLog, 'call-llm', 'adapterRawResponse');
302
560
 
303
561
  ### TopologyRecorder — Composition Graph for Streaming Consumers
304
562
 
305
- **One-liner:** reconstructs a live, queryable mini-flowchart of what your run actually traced, built from the 3 primitive recorder channels during traversal.
563
+ **One-liner:** reconstructs a live, queryable mini-flowchart of what your run actually traced, built from FlowRecorder events (`onSubflowEntry`/`onSubflowExit`, `onFork`, `onDecision`, `onLoop`) during traversal.
306
564
 
307
565
  **Mental model:**
308
566
 
@@ -310,22 +568,21 @@ const llmCommit = findCommit(commitLog, 'call-llm', 'adapterRawResponse');
310
568
  flowChart() builder → STATIC flowchart (design-time definition)
311
569
  │
312
570
  ▼ executor runs it
313
- Traversal emits events on 3 channels:
314
- Recorder · FlowRecorder · EmitRecorder
571
+ Traversal emits FlowRecorder events
315
572
  │
316
573
  ▼ TopologyRecorder listens
317
574
  DYNAMIC flowchart (runtime shape):
318
575
  Nodes = composition points
319
576
  (subflow / fork-branch / decision-branch)
320
577
  Edges = transitions
321
- (next / fork / decision / loop)
578
+ (next / fork-branch / decision-branch / loop-iteration)
322
579
  Queryable any moment — during or after run
323
580
  ```
324
581
 
325
582
  **What it IS:**
326
- - Live composition graph derived from 3 primitive channels
583
+ - Live composition graph derived from flow events
327
584
  - Each node = one composition-significant moment (subflow entered, fork child, decision chosen)
328
- - Each edge = a control-flow transition, timestamped with `runtimeStageId`
585
+ - Each edge = a control-flow transition, stamped with the `runtimeStageId` it happened at (`edge.at`)
329
586
  - Works identically during or after a run
330
587
 
331
588
  **What it ISN'T:**
@@ -338,17 +595,62 @@ flowChart() builder → STATIC flowchart (design-time definition)
338
595
  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.
339
596
 
340
597
  ```typescript
598
+ import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
341
599
  import { topologyRecorder } from 'footprintjs/trace';
342
600
 
601
+ const agent = flowChart<{ count: number }>('Start', (scope) => { scope.count = 0; }, 'start')
602
+ .addFunction('Tick', (scope) => { scope.count = scope.count + 1; }, 'tick')
603
+ .addDeciderFunction('Check', (scope) => decide(scope, [
604
+ { when: { count: { lt: 2 } }, then: 'again', label: 'under 2' },
605
+ ], 'done'), 'check')
606
+ .addFunctionBranch('again', 'Again', () => {})
607
+ .loopTo('tick')
608
+ .addFunctionBranch('done', 'Done', () => {})
609
+ .end()
610
+ .build();
611
+ const fanOut = flowChart<object>('Host', () => {}, 'host')
612
+ .addListOfFunction([
613
+ { id: 'x', name: 'X', fn: () => {} },
614
+ { id: 'y', name: 'Y', fn: () => {} },
615
+ ])
616
+ .build();
617
+ const chart = flowChart<object>('Seed', () => {}, 'seed')
618
+ .addSubFlowChartNext('sf-agent', agent, 'Agent')
619
+ .addSubFlowChartNext('sf-fanout', fanOut, 'FanOut')
620
+ .build();
621
+
622
+ const executor = new FlowChartExecutor(chart);
343
623
  const topo = topologyRecorder();
344
624
  executor.attachCombinedRecorder(topo); // auto-routes to FlowRecorder channel
345
625
 
346
- await executor.run({ input });
626
+ await executor.run();
347
627
 
348
628
  const { nodes, edges, activeNodeId, rootId } = topo.getTopology();
349
- topo.getSubflowNodes(); // agent-centric view
350
- topo.getByKind('fork-branch'); // all parallel branches
351
- topo.getParallelSiblings(id); // siblings of a parallel branch
629
+ console.log(nodes.map((node) => `${node.kind} ${node.id}`));
630
+ // [
631
+ // 'subflow sf-agent',
632
+ // 'decision-branch decision-sf-agent/check#4-sf-agent/Again',
633
+ // 'decision-branch decision-sf-agent/check#7-sf-agent/Done',
634
+ // 'subflow sf-fanout',
635
+ // 'fork-branch fork-sf-fanout/host#10-0-sf-fanout/X',
636
+ // 'fork-branch fork-sf-fanout/host#10-1-sf-fanout/Y'
637
+ // ]
638
+ console.log(edges.map((edge) => `${edge.kind}: ${edge.from} -> ${edge.to}`));
639
+ // [
640
+ // 'decision-branch: sf-agent -> decision-sf-agent/check#4-sf-agent/Again',
641
+ // 'loop-iteration: sf-agent -> sf-agent',
642
+ // 'decision-branch: sf-agent -> decision-sf-agent/check#7-sf-agent/Done',
643
+ // 'next: sf-agent -> sf-fanout',
644
+ // 'fork-branch: sf-fanout -> fork-sf-fanout/host#10-0-sf-fanout/X',
645
+ // 'fork-branch: sf-fanout -> fork-sf-fanout/host#10-1-sf-fanout/Y'
646
+ // ]
647
+ console.log(activeNodeId, rootId);
648
+ // null sf-agent
649
+ console.log(topo.getSubflowNodes().map((node) => node.id)); // agent-centric view
650
+ // [ 'sf-agent', 'sf-fanout' ]
651
+ console.log(topo.getByKind('fork-branch').map((node) => node.name)); // all parallel branches
652
+ // [ 'sf-fanout/X', 'sf-fanout/Y' ]
653
+ // topo.getParallelSiblings(id) — siblings of a parallel branch; topo.getChildren(id) — direct children
352
654
  ```
353
655
 
354
656
  **Three node kinds — complete composition coverage:**
@@ -359,16 +661,16 @@ topo.getParallelSiblings(id); // siblings of a parallel branch
359
661
  | `fork-branch` | `onFork` (synthesized one per child) | One branch of a parallel split — works for plain stages AND subflows |
360
662
  | `decision-branch` | `onDecision` (synthesized for chosen) | The chosen branch of a conditional |
361
663
 
362
- 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."
664
+ When a fork-branch or decision-branch target is also a subflow, the subsequent `onSubflowEntry` creates a subflow CHILD of the synthetic node (verified for a fork/decider at the top level of a run; inside another subflow the branch names carry the subflow path prefix and the subflow node attaches to the enclosing subflow instead). Layered shape preserves both "who branched" and "what the branch ran."
363
665
 
364
- **Edges:** one per control-flow transition. `edge.kind ∈ 'next' | 'fork-branch' | 'decision-branch' | 'loop-iteration'`. Each carries `at: runtimeStageId` for time correlation.
666
+ **Edges:** `edge.kind ∈ 'next' | 'fork-branch' | 'decision-branch' | 'loop-iteration'`. Each carries `at: runtimeStageId` for time correlation. Fork, decision and loop edges are recorded only while a subflow is active — a fork or decision at the top level of the run still creates its nodes, but no edge — and a `loopTo` outside any subflow adds nothing.
365
667
 
366
668
  **Correlation rules:**
367
669
  - `onFork({ parent, children })` → N `fork-branch` nodes synthesized up-front; subsequent matching `onSubflowEntry` nests under the right fork-branch
368
670
  - `onDecision({ chosen })` → `decision-branch` node synthesized up-front; matching `onSubflowEntry` nests under it
369
- - Pending correlation clears on `onSubflowExit` so state doesn't leak across scopes
671
+ - A pending decision clears on `onSubflowExit` so it can't match an unrelated subflow later; pending fork-sibling entries survive scope exits (a sibling's inner subflow may exit before the next sibling enters) and clear on the next `onFork` or on a match
370
672
  - `onLoop` → self-edge on the currently-active subflow (synthetic nodes don't participate)
371
- - Re-entry of same `subflowId` (loop body) disambiguates via `id#n` suffix
673
+ - Re-entry of same `subflowId` (loop body) disambiguates via `id#n` suffix (`sf-body`, `sf-body#1`)
372
674
 
373
675
  **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.
374
676
 
@@ -399,7 +701,7 @@ Each chart execution → 2 boundaries:
399
701
  - **Root** — `onRunStart` / `onRunEnd` fire ONCE per `executor.run()`. `subflowId: '__root__'`, `depth: 0`, `isRoot: true`.
400
702
  - **Subflow** — `onSubflowEntry` / `onSubflowExit` fire once per mounted subflow. Nested under root in the path tree (`['__root__', 'sf-x']`, depth 1+).
401
703
 
402
- Loop re-entry produces distinct pairs because the parent stage's executionIndex increments.
704
+ Loop re-entry produces distinct pairs because the parent stage's executionIndex increments (`sf-body#2`, `sf-body#7`).
403
705
 
404
706
  **What it IS:**
405
707
  - composes `SequenceStore<InOutEntry>` — flat ordered list + per-`runtimeStageId` index
@@ -413,18 +715,33 @@ Loop re-entry produces distinct pairs because the parent stage's executionIndex
413
715
  - Not agent-specific — domain libraries (e.g. agentfootprint) compose it; footprintjs owns it
414
716
 
415
717
  ```typescript
718
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
416
719
  import { inOutRecorder, ROOT_SUBFLOW_ID } from 'footprintjs/trace';
417
720
 
721
+ const sub = flowChart<{ q: number; r?: number }>('Double', (scope) => { scope.r = scope.q * 2; }, 'double').build();
722
+ const chart = flowChart<{ n: number; out?: number }>('Seed', (scope) => { scope.n = 21; }, 'seed')
723
+ .addSubFlowChartNext('sf-double', sub, 'Doubler', {
724
+ inputMapper: (parent: { n: number }) => ({ q: parent.n }),
725
+ outputMapper: (out: { r?: number }) => ({ out: out.r }),
726
+ })
727
+ .build();
728
+
729
+ const executor = new FlowChartExecutor(chart);
418
730
  const inOut = inOutRecorder();
419
731
  executor.attachCombinedRecorder(inOut);
420
732
 
421
- await executor.run({ input });
422
-
423
- inOut.getSteps(); // entry boundaries (timeline; root is first step)
424
- inOut.getBoundary(runtimeStageId); // { entry, exit } pair for one execution
425
- inOut.getRootBoundary(); // { entry, exit } for the top-level run
426
- inOut.getBoundaries(); // flat list (entry+exit interleaved)
427
- inOut.getEntryRanges(); // O(1) per-step range index for time-travel
733
+ await executor.run({ input: { start: true } });
734
+
735
+ console.log(inOut.getSteps().map((step) => step.runtimeStageId), ROOT_SUBFLOW_ID); // entry boundaries (timeline; root is first step)
736
+ // [ '__root__#0', 'sf-double#1' ] __root__
737
+ const { entry, exit } = inOut.getBoundary('sf-double#1')!; // { entry, exit } pair for one execution
738
+ console.log(JSON.stringify(entry?.payload), JSON.stringify(exit?.payload));
739
+ // {"q":21} {"q":21,"r":42}
740
+ const rootBoundary = inOut.getRootBoundary(); // { entry, exit } for the top-level run
741
+ console.log(JSON.stringify(rootBoundary?.entry?.payload), rootBoundary?.exit?.payload);
742
+ // {"start":true} undefined
743
+ // inOut.getBoundaries() — flat list (entry+exit interleaved)
744
+ // inOut.getEntryRanges() — O(1) per-step range index for time-travel
428
745
  ```
429
746
 
430
747
  **`InOutEntry` shape:**
@@ -442,7 +759,7 @@ inOut.getEntryRanges(); // O(1) per-step range index for time-trave
442
759
  | `payload` | `entry`: `inputMapper` result (subflow) or `run({input})` (root); `exit`: shared state at exit (subflow) or chart return value (root) |
443
760
  | `isRoot` | True only for the synthetic root pair from `onRunStart` / `onRunEnd` |
444
761
 
445
- **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.
762
+ **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`: `getBoundary()` returns `{ entry, exit: undefined }`. Resuming on the same executor appends a second root pair and a fresh pair for the re-entered subflow (new runtimeStageId) — the first leg's dangling `entry` stays as recorded.
446
763
 
447
764
  **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`.
448
765
 
@@ -450,7 +767,7 @@ inOut.getEntryRanges(); // O(1) per-step range index for time-trave
450
767
 
451
768
  Example: [examples/runtime-features/flow-recorder/07-inout.ts](examples/runtime-features/flow-recorder/07-inout.ts)
452
769
 
453
- **Three recorder STORAGE PRIMITIVES (v5 primary API)** — "one purpose per recorder": a store is storage ONLY. You own one as a field and implement the channel interface (`ScopeRecorder` / `FlowRecorder` / `EmitRecorder` / `CombinedRecorder`) yourself, delegating storage to the store. The abstract base classes (`KeyedRecorder` / `SequenceRecorder` / `BoundaryStateTracker`) were REMOVED in 7.0.0 — composition is the only model. Choose a store by data shape and durability:
770
+ **Three recorder STORAGE PRIMITIVES (since 5.0.0)** — "one purpose per recorder": a store is storage ONLY. You own one as a field and implement the channel interface (`ScopeRecorder` / `FlowRecorder` / `EmitRecorder` / `CombinedRecorder`) yourself, delegating storage to the store. The abstract base classes (`KeyedRecorder` / `SequenceRecorder` / `BoundaryStateTracker`) were REMOVED in 7.0.0 — composition is the only model. Choose a store by data shape and durability:
454
771
 
455
772
  | Store | Relationship | Time scope | Use When |
456
773
  |------------|-------------|------------|----------|
@@ -459,14 +776,18 @@ Example: [examples/runtime-features/flow-recorder/07-inout.ts](examples/runtime-
459
776
  | `BoundaryStateStore<TState>` | Map\<key, TState\> active bracket | transient — clears on stop | Live state DURING a `[start, stop]` bracket (LLM stream partial, tool args streaming) |
460
777
 
461
778
  ```typescript
779
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
780
+ import type { FlowRecorder, EmitRecorder, EmitEvent, FlowStageEvent, FlowDecisionEvent } from 'footprintjs';
462
781
  import { KeyedStore, SequenceStore, BoundaryStateStore } from 'footprintjs/trace';
463
- import type { FlowRecorder, EmitRecorder } from 'footprintjs';
464
782
 
465
783
  // KeyedStore: one entry per step. Own the store; implement the channel.
466
784
  class TokenRecorder implements FlowRecorder {
467
785
  readonly id = 'tokens';
468
786
  private store = new KeyedStore<{ tokens: number }>();
469
- onStageExecuted(e) { this.store.set(e.traversalContext.runtimeStageId, { tokens: 0 }); }
787
+ onStageExecuted(e: FlowStageEvent) {
788
+ const rid = e.traversalContext?.runtimeStageId;
789
+ if (rid) this.store.set(rid, { tokens: 10 });
790
+ }
470
791
  byStep(rid: string) { return this.store.get(rid); } // Translate: per-step value
471
792
  total() { return this.store.aggregate((sum, e) => sum + e.tokens, 0); } // Aggregate: grand total
472
793
  upTo(keys: ReadonlySet<string>) { // Accumulate: up to slider
@@ -478,7 +799,9 @@ class TokenRecorder implements FlowRecorder {
478
799
  class AuditRecorder implements FlowRecorder {
479
800
  readonly id = 'audit';
480
801
  private store = new SequenceStore<{ runtimeStageId: string; type: string }>();
481
- onDecision(e) { this.store.push({ runtimeStageId: e.traversalContext?.runtimeStageId, type: 'decision' }); }
802
+ onDecision(e: FlowDecisionEvent) {
803
+ this.store.push({ runtimeStageId: e.traversalContext?.runtimeStageId ?? '', type: 'decision' });
804
+ }
482
805
  forStep(rid: string) { return this.store.getByKey(rid); } // Translate: per-step entries
483
806
  upTo(keys: ReadonlySet<string>) { return this.store.getEntriesUpTo(keys); } // Progressive: up to slider
484
807
  ranges() { return this.store.getEntryRanges(); } // Range index: O(1) slider sync
@@ -488,9 +811,10 @@ class AuditRecorder implements FlowRecorder {
488
811
  class LiveLLMTracker implements EmitRecorder {
489
812
  readonly id = 'live-llm';
490
813
  private store = new BoundaryStateStore<{ partial: string; tokens: number }>();
491
- onEmit(e) {
814
+ onEmit(e: EmitEvent) {
815
+ const payload = e.payload as { content?: string };
492
816
  if (e.name === 'llm.start') this.store.start(e.runtimeStageId, { partial: '', tokens: 0 });
493
- if (e.name === 'llm.token') this.store.update(e.runtimeStageId, s => ({ partial: s.partial + e.payload.content, tokens: s.tokens + 1 }));
817
+ if (e.name === 'llm.token') this.store.update(e.runtimeStageId, (s) => ({ partial: s.partial + (payload.content ?? ''), tokens: s.tokens + 1 }));
494
818
  if (e.name === 'llm.end') this.store.stop(e.runtimeStageId);
495
819
  }
496
820
  isInFlight() { return this.store.hasActive; } // O(1) — live read
@@ -498,33 +822,81 @@ class LiveLLMTracker implements EmitRecorder {
498
822
  concurrent() { return this.store.activeCount; } // O(1) — how many concurrent boundaries
499
823
  }
500
824
  // Lifecycle: call store.clear() between runs; dev-mode warns on leaked-stop bugs.
825
+
826
+ const tokens = new TokenRecorder();
827
+ const audit = new AuditRecorder();
828
+ const live = new LiveLLMTracker();
829
+
830
+ const chart = flowChart<object>('Ask', (scope) => {
831
+ scope.$emit('llm.start', {});
832
+ scope.$emit('llm.token', { content: 'Hel' });
833
+ scope.$emit('llm.token', { content: 'lo' });
834
+ console.log(live.isInFlight(), live.concurrent(), live.getPartial('ask#0')); // mid-bracket
835
+ scope.$emit('llm.end', {});
836
+ }, 'ask')
837
+ .addDeciderFunction('Check', () => 'ok', 'check')
838
+ .addFunctionBranch('ok', 'Ok', () => {})
839
+ .end()
840
+ .build();
841
+
842
+ const executor = new FlowChartExecutor(chart);
843
+ executor.attachFlowRecorder(tokens);
844
+ executor.attachFlowRecorder(audit);
845
+ executor.attachEmitRecorder(live);
846
+ await executor.run();
847
+
848
+ console.log(tokens.total(), tokens.byStep('ask#0'), tokens.upTo(new Set(['ask#0'])));
849
+ console.log(live.isInFlight(), live.concurrent(), JSON.stringify(audit.forStep('check#1')), audit.ranges().size);
850
+ // true 1 Hello
851
+ // 30 { tokens: 10 } 10
852
+ // false 0 [{"runtimeStageId":"check#1","type":"decision"}] 1
501
853
  ```
502
854
 
503
855
  **`getEntryRanges()`** returns a precomputed `Map<runtimeStageId, {firstIdx, endIdx}>` maintained during `push()`. Use for O(1) per-step range lookups during time-travel scrubbing. Same shape as `buildEntryRangeIndex()` in `footprint-explainable-ui`.
504
856
 
505
- **`CombinedNarrativeEntry.direction`** — subflow entries carry `direction: 'entry' | 'exit'`. Use for programmatic subflow boundary detection instead of text scanning (which breaks with custom `NarrativeRenderer`).
857
+ **`CombinedNarrativeEntry.direction`** — subflow entries carry `direction: 'entry' | 'exit'` (and `subflowId`). Use for programmatic subflow boundary detection instead of text scanning (which breaks with a custom `NarrativeFormatter`; `NarrativeRenderer` is its deprecated alias).
506
858
 
507
- **`footprint-explainable-ui` narrative utilities** — for consumers building custom shells without `ExplainableShell`:
859
+ **`footprint-explainable-ui` narrative utilities** (read from that package's source, 0.38.0) — for consumers building custom shells without `ExplainableShell`:
508
860
  - `buildEntryRangeIndex(entries)` — build range index from flat array (when no recorder access)
509
861
  - `computeRevealedEntryCount(entries, snapshots, idx, rangeIndex?)` — slider position → entry count
510
862
  - `extractSubflowNarrative(entries, subflowId)` — three-tier subflow entry extraction
511
863
 
512
- **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`.
864
+ **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 the engine's `buildRuntimeStageId(prefixedId, idx)` just appends `#index`.
513
865
 
514
866
  ## Dev Mode
515
867
 
516
- One global flag (`enableDevMode()` / `disableDevMode()` / `isDevMode()`) controls every developer-only diagnostic across the library. OFF by default — production pays zero overhead.
868
+ One global flag (`enableDevMode()` / `disableDevMode()` / `isDevMode()`) controls every developer-only diagnostic across the library. OFF by default — the diagnostics below run only when it is on.
869
+
870
+ ```typescript
871
+ import { flowChart, FlowChartExecutor, enableDevMode, disableDevMode, isDevMode } from 'footprintjs';
872
+
873
+ console.log(isDevMode());
874
+ // false
875
+ enableDevMode(); // in a real app: if (process.env.NODE_ENV !== 'production') enableDevMode();
876
+
877
+ const warnings: string[] = [];
878
+ console.warn = (message: string) => { warnings.push(message.split(' — ')[0]); };
517
879
 
518
- ```ts
519
- import { enableDevMode } from 'footprintjs';
520
- if (process.env.NODE_ENV !== 'production') enableDevMode();
880
+ const executor = new FlowChartExecutor(flowChart<{ n: number }>('Seed', (scope) => { scope.n = 1; }, 'seed').build());
881
+ executor.attachCombinedRecorder({ id: 'empty' }); // no on* handler: warns
882
+ await executor.run();
883
+ console.log(warnings);
884
+ // [
885
+ // "[footprintjs] attachCombinedRecorder: recorder 'empty' has no observer event methods"
886
+ // ]
887
+ console.log(Object.isFrozen(executor.getSnapshot().sharedState)); // dev mode: a deep-frozen clone, not the live state
888
+ // true
889
+ disableDevMode();
521
890
  ```
522
891
 
523
- Gated diagnostics:
524
- - **Circular-ref detection** in `ScopeFacade.setValue()` — O(n) WeakSet traversal per write
892
+ Gated diagnostics (not exhaustive; each is gated on `isDevMode()` in the source):
893
+ - **Circular-ref detection** in scope writes (`ScopeFacade.setValue()`) — O(n) WeakSet traversal per write
525
894
  - **Empty-recorder warning** in `attachCombinedRecorder(r)` — catches `r` with no `on*` handler
526
- - **Suspicious predicates** in `decide()` / `select()`
895
+ - **`decide()` / `select()` rules** that throw while evaluating, or filter ops that are not one of `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn` (the rule counts as not matched)
527
896
  - **Snapshot integrity** in `getSubtreeSnapshot()`
897
+ - **Recorder hook errors** — a recorder hook that throws is isolated and, in dev mode, warned about; it does not abort the run — except `onResume`, which the executor calls unguarded (a throw rejects `resume()`), and, in dev mode, a hook that throws a value that cannot be stringified (a null-prototype object rejects `run()`).
898
+ - **`getSnapshot().sharedState`** becomes a deep-frozen clone, so a mutation throws instead of corrupting engine state
899
+ - **A subflow id mounted twice** — warned at build time (a pause inside it could not be resumed)
528
900
 
529
901
  Convention: when adding a new dev-only check, gate on `isDevMode()` (from `lib/devMode.ts`). Do NOT use `process.env.NODE_ENV` inline — consumers control dev tooling centrally via `enableDevMode()`/`disableDevMode()`, and inline env checks break that contract.
530
902
 
@@ -534,48 +906,119 @@ Convention: when adding a new dev-only check, gate on `isDevMode()` (from `lib/d
534
906
 
535
907
  By default, an inner subflow's `$break` stops ONLY the subflow; the parent continues. Opt into propagation via `SubflowMountOptions.propagateBreak: true`:
536
908
 
537
- ```ts
538
- builder.addSubFlowChartNext('sf-escalate', escalateChart, 'Escalate', {
539
- inputMapper: ..., outputMapper: ...,
540
- propagateBreak: true, // ← inner $break → parent $break, with reason
541
- });
909
+ ```typescript
910
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
911
+
912
+ const escalateChart = flowChart<{ partial?: string; later?: boolean }>('Escalate', (scope) => {
913
+ scope.partial = 'from-subflow';
914
+ scope.$break('policy violation');
915
+ }, 'escalate')
916
+ .addFunction('NeverRuns', (scope) => { scope.later = true; }, 'never-runs')
917
+ .build();
918
+
919
+ for (const propagateBreak of [false, true]) {
920
+ const chart = flowChart<{ seed?: boolean; got?: string; after?: boolean }>('Seed', (scope) => { scope.seed = true; }, 'seed')
921
+ .addSubFlowChartNext('sf-escalate', escalateChart, 'Escalate', {
922
+ outputMapper: (sub: { partial?: string }) => ({ got: sub.partial }),
923
+ propagateBreak, // ← inner $break → parent $break, with reason
924
+ })
925
+ .addFunction('After', (scope) => { scope.after = true; }, 'after')
926
+ .build();
927
+ const executor = new FlowChartExecutor(chart);
928
+ const breaks: string[] = [];
929
+ executor.attachFlowRecorder({
930
+ id: 'breaks',
931
+ onBreak: (e) => { breaks.push(`${e.stageName}: ${e.reason}${e.propagatedFromSubflow ? ` (from ${e.propagatedFromSubflow})` : ''}`); },
932
+ });
933
+ await executor.run();
934
+ console.log(propagateBreak, JSON.stringify(executor.getSnapshot().sharedState), breaks);
935
+ }
936
+ // false {"seed":true,"got":"from-subflow","after":true} [ 'sf-escalate/Escalate: policy violation' ]
937
+ // true {"seed":true,"got":"from-subflow"} [
938
+ // 'sf-escalate/Escalate: policy violation',
939
+ // 'Escalate: policy violation (from sf-escalate)'
940
+ // ]
542
941
  ```
543
942
 
544
943
  Semantics:
545
944
  - **Linear chain:** inner `$break(reason)` → parent's `breakFlag` flips → next parent stage does NOT run → `FlowBreakEvent` fires at parent-mount level with `propagatedFromSubflow` + reason.
546
- - **Nested chain:** propagates through every hop that opted in. Reason survives.
547
- - **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.
548
- - **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.
945
+ - **Nested chain:** propagates through every hop that opted in (a hop that did not opt in stops the propagation there). Reason survives.
946
+ - **outputMapper still runs** before propagation — subflow's partial state lands in parent before the break (`got` above). Escape hatch: early-return `{}` from outputMapper when the break state is set.
947
+ - **Parallel/fan-out:** a `$break` in a fork child stops that child; the parent continues after the fork even when EVERY child broke (the "all children broke" rule in `ChildrenExecutor` is not wired in 9.32.0). `propagateBreak: true` does not change that.
549
948
 
550
949
  Example: [examples/runtime-features/break/04-subflow-propagate.ts](examples/runtime-features/break/04-subflow-propagate.ts).
551
950
 
552
951
  ## Emit Channel (Phase 3)
553
952
 
554
- 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.
953
+ Third observer channel alongside `ScopeRecorder` (data-flow) and `FlowRecorder` (control-flow). Consumer stage code emits structured events; `EmitRecorder.onEmit(event)` fires synchronously with auto-enriched context.
555
954
 
556
- ```ts
955
+ ```typescript
956
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
557
957
  import type { EmitRecorder, EmitEvent } from 'footprintjs';
558
958
 
559
- // Inside a stage:
560
- scope.$emit('myapp.llm.tokens', { input: 100, output: 50 });
561
-
562
- // Recorder observes:
959
+ const tallies: unknown[] = [];
563
960
  const rec: EmitRecorder = {
564
961
  id: 'token-meter',
565
- onEmit: (e) => { if (e.name === 'myapp.llm.tokens') tally(e.payload); },
962
+ onEmit: (e) => { if (e.name === 'myapp.llm.tokens') tallies.push(e.payload); },
566
963
  };
567
- executor.attachEmitRecorder(rec);
964
+
965
+ const sub = flowChart<object>('SubStage', (scope) => { scope.$emit('myapp.sub.event', { in: 'sub' }); }, 'sub-stage').build();
966
+ const chart = flowChart<object>('Meter', (scope) => {
967
+ // Inside a stage:
968
+ scope.$emit('myapp.llm.tokens', { input: 100, output: 50 });
969
+ scope.$emit('myapp.auth.check', { secret: 'x' }); // matched by emitPatterns below
970
+ scope.$debug('k', 1); scope.$error('e', 2); scope.$metric('m', 3); scope.$eval('ev', 4); scope.$log('hello');
971
+ }, 'meter')
972
+ .addSubFlowChartNext('sf', sub, 'Sub')
973
+ .build();
974
+
975
+ const executor = new FlowChartExecutor(chart);
976
+ executor.enableNarrative({
977
+ renderer: {
978
+ renderEmit: (ctx) => (ctx.name === 'myapp.llm.tokens' ? `Tokens: ${JSON.stringify(ctx.payload)}` : undefined),
979
+ },
980
+ });
981
+ executor.setRedactionPolicy({ emitPatterns: [/\.auth\./] });
982
+ executor.attachEmitRecorder(rec); // Recorder observes
983
+ const seen: EmitEvent[] = [];
984
+ executor.attachEmitRecorder({ id: 'all', onEmit: (e) => { seen.push(e); } });
985
+ await executor.run();
986
+
987
+ console.log(JSON.stringify(tallies));
988
+ // [{"input":100,"output":50}]
989
+ console.log(seen.map((e) => e.name));
990
+ // [
991
+ // 'myapp.llm.tokens',
992
+ // 'myapp.auth.check',
993
+ // 'log.debug.k',
994
+ // 'log.error.e',
995
+ // 'metric.m',
996
+ // 'eval.ev',
997
+ // 'log.debug.messages',
998
+ // 'myapp.sub.event'
999
+ // ]
1000
+ const { timestamp, ...enriched } = seen[seen.length - 1];
1001
+ console.log(JSON.stringify(enriched), typeof timestamp);
1002
+ // {"name":"myapp.sub.event","payload":{"in":"sub"},"stageName":"sf/SubStage","runtimeStageId":"sf/sub-stage#2","subflowPath":["sf"],"pipelineId":""} number
1003
+ console.log(JSON.stringify(seen[1].payload));
1004
+ // "[REDACTED]"
1005
+ console.log(executor.getNarrativeEntries().filter((e) => e.type === 'emit').map((e) => e.text).slice(0, 3));
1006
+ // [
1007
+ // 'Tokens: {"input":100,"output":50}',
1008
+ // '[emit] myapp.auth.check: "[REDACTED]"',
1009
+ // '[emit] log.debug.k: {key, value, level}'
1010
+ // ]
568
1011
  ```
569
1012
 
570
1013
  ### Semantics
571
- - **Pass-through.** Delivered synchronously, in call order. Zero allocation when no recorder attached (fast-path in `ScopeFacade.emitEvent`).
572
- - **Auto-enriched.** Events carry `stageName`, `runtimeStageId`, `subflowPath`, `pipelineId`, `timestamp` — parsed from `runtimeStageId` for subflow context.
1014
+ - **Pass-through.** Delivered synchronously, in call order. `ScopeFacade.emitEvent` returns immediately when no recorder is attached.
1015
+ - **Auto-enriched.** Events carry `stageName`, `runtimeStageId`, `subflowPath`, `pipelineId`, `timestamp` — in a subflow, the stage name and runtimeStageId carry the path prefix and `subflowPath` lists it.
573
1016
  - **Error-isolated.** A throwing `onEmit` doesn't propagate; errors route to `onError` on other recorders.
574
1017
  - **Redactable.** `RedactionPolicy.emitPatterns: RegExp[]` matches `event.name`; matched payloads become `'[REDACTED]'` before dispatch.
575
1018
  - **Buffered in narrative.** `CombinedNarrativeRecorder.onEmit` buffers alongside reads/writes; flushed in `flushOps` so emit entries appear AFTER the stage header in ordered narrative.
576
1019
 
577
1020
  ### Naming convention
578
- Hierarchical dotted names — `<namespace>.<category>.<event>`. Examples:
1021
+ Hierarchical dotted names — `<namespace>.<category>.<event>` (a convention: any name is accepted). Examples:
579
1022
  - `'agentfootprint.llm.tokens'`, `'agentfootprint.llm.request'`
580
1023
  - `'myapp.billing.spend'`, `'myapp.auth.check'`
581
1024
 
@@ -587,12 +1030,13 @@ $debug(key, value) → emits 'log.debug.${key}'
587
1030
  $error(key, value) → emits 'log.error.${key}'
588
1031
  $metric(name, value) → emits 'metric.${name}'
589
1032
  $eval (name, value) → emits 'eval.${name}'
1033
+ $log(value) → emits 'log.debug.messages'
590
1034
  ```
591
1035
 
592
- 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.
1036
+ So `$metric` / `$debug` are observable by recorders in real time; the side bags still populate for consumers that inspect snapshots directly.
593
1037
 
594
1038
  ### Customizing narrative rendering
595
- `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`.
1039
+ `NarrativeFormatter.renderEmit?(ctx)` hook (passed as `enableNarrative({ renderer })`) 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` (above: the first line is the custom one, the rest are defaults).
596
1040
 
597
1041
  Example: [examples/runtime-features/emit/01-custom-events.ts](examples/runtime-features/emit/01-custom-events.ts).
598
1042
 
@@ -600,9 +1044,11 @@ Example: [examples/runtime-features/emit/01-custom-events.ts](examples/runtime-f
600
1044
 
601
1045
  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.
602
1046
 
603
- ```ts
1047
+ ```typescript
1048
+ import { flowChart, FlowChartExecutor, isFlowEvent } from 'footprintjs';
604
1049
  import type { CombinedRecorder } from 'footprintjs';
605
- import { isFlowEvent } from 'footprintjs';
1050
+
1051
+ const log = (...parts: unknown[]) => console.log(...parts);
606
1052
 
607
1053
  const audit: CombinedRecorder = {
608
1054
  id: 'audit',
@@ -615,31 +1061,61 @@ const audit: CombinedRecorder = {
615
1061
  },
616
1062
  };
617
1063
 
1064
+ const chart = flowChart<{ n: number }>('Seed', (scope) => { scope.n = 1; }, 'seed')
1065
+ .addFunction('Boom', () => { throw new Error('kaput'); }, 'boom')
1066
+ .build();
1067
+
1068
+ const executor = new FlowChartExecutor(chart);
618
1069
  executor.attachCombinedRecorder(audit);
1070
+ await executor.run().catch(() => undefined);
1071
+ // scope write n
1072
+ // flow error in Boom
619
1073
  ```
620
1074
 
621
- Built on `CombinedRecorder`: `CombinedNarrativeRecorder` (the `executor.enableNarrative()` default). Consumers implement ONLY the events they care about — `Partial<Recorder> & Partial<FlowRecorder>` under the hood.
1075
+ Built on `CombinedRecorder`: `CombinedNarrativeRecorder` (the `executor.enableNarrative()` default). Consumers implement ONLY the events they care about — `Partial<ScopeRecorder> & Partial<FlowRecorder> & Partial<EmitRecorder>` under the hood.
622
1076
 
623
- **Detection rule:** only OWN event-method properties count (prototype methods are ignored for security — prevents accidental `Object.prototype` pollution from attaching handlers).
1077
+ **Detection rule:** a handler counts when it is an OWN property OR a method on the recorder's class prototype chain — class instances work. Only handlers inherited from `Object.prototype` are ignored (prevents accidental `Object.prototype` pollution from attaching handlers).
624
1078
 
625
1079
  ## Anti-Patterns
626
1080
 
627
- - Never post-process the tree — use recorders
628
- - Don't use `getValue()`/`setValue()` in TypedScope stages — use typed property access
629
- - Don't use `$`-prefixed state keys (e.g., `$break`) — they collide with ScopeMethods
630
- - Don't use deprecated `CombinedNarrativeBuilder` — use `CombinedNarrativeRecorder`
631
- - Don't extract shared base for Recorder/FlowRecorder — two instances = coincidence
1081
+ - Never post-process the tree — use recorders (or the `footprintjs/trace` queries over the recorded log)
1082
+ - Don't use `getValue()`/`setValue()` for keys you know in TypedScope stages — use typed property access (`$getValue`/`$setValue` are for dynamic keys; plain `scope.getValue` does not exist there)
1083
+ - Don't give a state key the name of a `$` method (`scope.$break = 1` throws "conflicts with a reserved TypedScope method") — the reserved names are `SCOPE_METHOD_NAMES` in `footprintjs/advanced`; avoid `$`-prefixed state keys altogether
1084
+ - Don't write a state key that is also a `run({ input })` key — input keys are read-only for the run
1085
+ - `CombinedNarrativeBuilder` is gone (removed in v1.0) — the narrative comes from `CombinedNarrativeRecorder` via `executor.enableNarrative()` or the `narrative()` factory
1086
+ - Don't extract a shared base for scope and flow recorders — built-ins compose a store (`KeyedStore` / `SequenceStore`) as a field
632
1087
  - Don't use `getArgs()` for tracked data — use typed scope properties
633
1088
  - Don't put infrastructure data in `getArgs()` — use `getEnv()` via `run({ env })`
634
- - Don't manually create `CombinedNarrativeRecorder` — `executor.recorder(narrative())` handles it
635
- - 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.
1089
+ - Don't hand-roll a recorder to get the narrative — `executor.enableNarrative()` (or `chart.recorder(narrative())`) is the whole setup, and it is off until called
1090
+ - Don't end a loop with `return` — `.loopTo(id)` is unconditional; exit through a decider branch or `scope.$break()`
1091
+ - 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. `ArrayMergeMode` is exported from `footprintjs/advanced` (not the main door).
1092
+
1093
+ ```typescript
1094
+ import { flowChart, FlowChartExecutor } from 'footprintjs';
1095
+ import { ArrayMergeMode } from 'footprintjs/advanced';
1096
+
1097
+ const sub = flowChart<{ items?: string[] }>('Sub', (scope) => { scope.items = ['a', 'b']; }, 'sub').build();
1098
+ for (const arrayMerge of [ArrayMergeMode.Concat, ArrayMergeMode.Replace]) {
1099
+ const chart = flowChart<{ items: string[] }>('Seed', (scope) => { scope.items = ['a']; }, 'seed')
1100
+ .addSubFlowChartNext('sf', sub, 'Sub', { outputMapper: (out: { items?: string[] }) => ({ items: out.items }), arrayMerge })
1101
+ .build();
1102
+ const executor = new FlowChartExecutor(chart);
1103
+ await executor.run();
1104
+ console.log(arrayMerge, JSON.stringify(executor.getSnapshot().sharedState.items));
1105
+ }
1106
+ // concat ["a","a","b"]
1107
+ // replace ["a","b"]
1108
+ ```
636
1109
 
637
1110
  ## Build & Test
638
1111
 
639
1112
  ```bash
640
- npm run build # tsc (CJS) + tsc -p tsconfig.esm.json (ESM)
641
- npm test # full suite
642
- npm run test:unit
1113
+ npm run build # tsc (CJS) + tsc -p tsconfig.esm.json (ESM) + scripts/postbuild-esm.mjs
1114
+ npm test # full suite (vitest)
1115
+ npm run test:examples # type-check examples/
1116
+ npm run lint
1117
+ npm run check:layering # the layering rule, from scripts/layering.config.cjs
1118
+ npm run check:doc-snippets
643
1119
  ```
644
1120
 
645
1121
  Dual output: CommonJS (`dist/`) + ESM (`dist/esm/`) + types (`dist/types/`)