hcifootprint 0.2.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 (87) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +524 -0
  3. package/dist/atom/types.d.ts +618 -0
  4. package/dist/atom/types.d.ts.map +1 -0
  5. package/dist/atom/types.js +2 -0
  6. package/dist/atom/types.js.map +1 -0
  7. package/dist/graph/builder.d.ts +31 -0
  8. package/dist/graph/builder.d.ts.map +1 -0
  9. package/dist/graph/builder.js +165 -0
  10. package/dist/graph/builder.js.map +1 -0
  11. package/dist/graph/guards.d.ts +31 -0
  12. package/dist/graph/guards.d.ts.map +1 -0
  13. package/dist/graph/guards.js +94 -0
  14. package/dist/graph/guards.js.map +1 -0
  15. package/dist/graph/skill-deps.d.ts +20 -0
  16. package/dist/graph/skill-deps.d.ts.map +1 -0
  17. package/dist/graph/skill-deps.js +15 -0
  18. package/dist/graph/skill-deps.js.map +1 -0
  19. package/dist/index.d.ts +49 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +42 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/mcp.d.ts +16 -0
  24. package/dist/mcp.d.ts.map +1 -0
  25. package/dist/mcp.js +15 -0
  26. package/dist/mcp.js.map +1 -0
  27. package/dist/presence/presence.d.ts +50 -0
  28. package/dist/presence/presence.d.ts.map +1 -0
  29. package/dist/presence/presence.js +126 -0
  30. package/dist/presence/presence.js.map +1 -0
  31. package/dist/registry/registry.d.ts +53 -0
  32. package/dist/registry/registry.d.ts.map +1 -0
  33. package/dist/registry/registry.js +76 -0
  34. package/dist/registry/registry.js.map +1 -0
  35. package/dist/serve/mcp-server.d.ts +40 -0
  36. package/dist/serve/mcp-server.d.ts.map +1 -0
  37. package/dist/serve/mcp-server.js +72 -0
  38. package/dist/serve/mcp-server.js.map +1 -0
  39. package/dist/serve/mcp.d.ts +12 -0
  40. package/dist/serve/mcp.d.ts.map +1 -0
  41. package/dist/serve/mcp.js +62 -0
  42. package/dist/serve/mcp.js.map +1 -0
  43. package/dist/serve/modes.d.ts +66 -0
  44. package/dist/serve/modes.d.ts.map +1 -0
  45. package/dist/serve/modes.js +347 -0
  46. package/dist/serve/modes.js.map +1 -0
  47. package/dist/testing/harness.d.ts +156 -0
  48. package/dist/testing/harness.d.ts.map +1 -0
  49. package/dist/testing/harness.js +416 -0
  50. package/dist/testing/harness.js.map +1 -0
  51. package/dist/testing/index.d.ts +35 -0
  52. package/dist/testing/index.d.ts.map +1 -0
  53. package/dist/testing/index.js +32 -0
  54. package/dist/testing/index.js.map +1 -0
  55. package/dist/testing/lint.d.ts +20 -0
  56. package/dist/testing/lint.d.ts.map +1 -0
  57. package/dist/testing/lint.js +18 -0
  58. package/dist/testing/lint.js.map +1 -0
  59. package/dist/testing/model/check.d.ts +30 -0
  60. package/dist/testing/model/check.d.ts.map +1 -0
  61. package/dist/testing/model/check.js +89 -0
  62. package/dist/testing/model/check.js.map +1 -0
  63. package/dist/testing/model/lint.d.ts +54 -0
  64. package/dist/testing/model/lint.d.ts.map +1 -0
  65. package/dist/testing/model/lint.js +235 -0
  66. package/dist/testing/model/lint.js.map +1 -0
  67. package/dist/testing/model/satisfiable.d.ts +25 -0
  68. package/dist/testing/model/satisfiable.d.ts.map +1 -0
  69. package/dist/testing/model/satisfiable.js +101 -0
  70. package/dist/testing/model/satisfiable.js.map +1 -0
  71. package/dist/traverse/nav-session.d.ts +108 -0
  72. package/dist/traverse/nav-session.d.ts.map +1 -0
  73. package/dist/traverse/nav-session.js +637 -0
  74. package/dist/traverse/nav-session.js.map +1 -0
  75. package/dist/traverse/session.d.ts +239 -0
  76. package/dist/traverse/session.d.ts.map +1 -0
  77. package/dist/traverse/session.js +1305 -0
  78. package/dist/traverse/session.js.map +1 -0
  79. package/dist/tree/appmap.d.ts +8 -0
  80. package/dist/tree/appmap.d.ts.map +1 -0
  81. package/dist/tree/appmap.js +259 -0
  82. package/dist/tree/appmap.js.map +1 -0
  83. package/dist/tree/types.d.ts +151 -0
  84. package/dist/tree/types.d.ts.map +1 -0
  85. package/dist/tree/types.js +2 -0
  86. package/dist/tree/types.js.map +1 -0
  87. package/package.json +90 -0
@@ -0,0 +1,1305 @@
1
+ /**
2
+ * Session — the traverse() driver: available / fire / sync.
3
+ *
4
+ * footprint's executor drives a chart to completion and its decide() picks one
5
+ * branch. A live UI is inverted: the user/agent drives edge-by-edge, and from
6
+ * any node the driver exposes all guard-passing edges and waits for the world
7
+ * to pick one. So this driver never touches footprint's engine. It reuses the
8
+ * transactional memory/commit/trace stack instead:
9
+ *
10
+ * one fired-or-stimulus transition
11
+ * → one fresh StageContext (runId '' = root namespace)
12
+ * → tracked reads (guard keys) + tracked writes (the settled delta)
13
+ * → commit() → one CommitBundle in the EventLog
14
+ *
15
+ * which makes footprint's whole post-hoc toolchain (causalChain, sliceForKey,
16
+ * arrayProvenance, commitValueAt) work on UI sessions unchanged. Guard
17
+ * evaluation itself is footprint's pure `evaluateFilter` — no scope, no
18
+ * commit, evidence-emitting, worker-safe.
19
+ *
20
+ * Longevity rules honored (from the footprint execution-model adjudication):
21
+ * fresh context per transition (never createNext — it retains every context),
22
+ * runId stays '' (non-empty runIds namespace writes and break slice matching),
23
+ * runtimeStageId uniqueness via a monotonic counter.
24
+ */
25
+ import { EventLog, ScopeFacade, SharedMemory, StageContext, buildRuntimeStageId, createExecutionCounter, evaluateFilter, } from 'footprintjs/advanced';
26
+ import { formatSlice, keysReadFromMap, sliceForKey } from 'footprintjs/trace';
27
+ import { edgesToMCPTools, leaveSkillTool } from '../serve/mcp.js';
28
+ import { stepDependencies } from '../graph/skill-deps.js';
29
+ import { ToolRegistry } from '../registry/registry.js';
30
+ export class Session {
31
+ #spec;
32
+ #node;
33
+ #version = 0;
34
+ /** Committed state deltas only (settle / stimulus / inference). */
35
+ #stateVersion = 0;
36
+ /** Served-structure changes only (frames + coalesced registration/presence swaps). */
37
+ #structureVersion = 0;
38
+ /** Whether updateState() reports are expected (see SessionOptions.stateTap). */
39
+ #stateTap;
40
+ /** Whether handler return values are captured onto records (act → data back). */
41
+ #captureProduced;
42
+ /** Fingerprint of the served structure at the last coalesced flush. */
43
+ #structureFingerprint = '';
44
+ #structureFlushScheduled = false;
45
+ #heap;
46
+ #log;
47
+ #counter;
48
+ #redacted;
49
+ #commitValues;
50
+ #transitions = [];
51
+ #pending = [];
52
+ /** runtimeStageId → keys tracked-read, collected live via the scope channel. */
53
+ #readsByStep = new Map();
54
+ #recorder;
55
+ /** The one open skill frame (v0: one at a time). */
56
+ #frame = null;
57
+ /**
58
+ * Record id whose handler is executing its SYNCHRONOUS portion right now.
59
+ * updateState() called from inside that portion attributes directly to this
60
+ * record (like transitionId targeting) — the fix for the burst-fire race
61
+ * where another handler's report would FIFO-steal an earlier record.
62
+ */
63
+ #invokingRecordId = null;
64
+ /** Closed frames (completed / cancelled / demoted), oldest first. */
65
+ #frames = [];
66
+ #registry;
67
+ #warn;
68
+ /** Unmet demand: rejected fires + explicitly reported unserved asks. */
69
+ #gaps = [];
70
+ /** Passive observer listeners, by event name (the recorder category, session grain). */
71
+ #listeners = new Map();
72
+ /** Monotonic counter for generated tool-group ids (never caller-supplied). */
73
+ #groupSeq = 0;
74
+ constructor(spec, opts) {
75
+ if (!spec.pages[opts.node]) {
76
+ throw new Error(`hcifootprint: unknown starting node '${opts.node}'. Known pages: ${Object.keys(spec.pages).join(', ')}.`);
77
+ }
78
+ this.#spec = spec;
79
+ this.#node = opts.node;
80
+ this.#stateTap = opts.stateTap ?? opts.state !== undefined;
81
+ this.#captureProduced = opts.captureProduced ?? true;
82
+ const initial = structuredClone(opts.state ?? {});
83
+ this.#log = new EventLog(initial);
84
+ this.#heap = new SharedMemory(undefined, initial);
85
+ this.#counter = createExecutionCounter();
86
+ this.#redacted = new Set(opts.redactedKeys ?? []);
87
+ this.#commitValues = opts.commitValues ?? 'delta';
88
+ this.#warn = opts.onWarn ?? ((message) => console.warn(message));
89
+ this.#registry = new ToolRegistry(this.#warn);
90
+ this.#recorder = {
91
+ id: 'hcifootprint-session',
92
+ onRead: (event) => {
93
+ if (!event.key || !event.runtimeStageId)
94
+ return;
95
+ const reads = this.#readsByStep.get(event.runtimeStageId) ?? [];
96
+ reads.push(event.key);
97
+ this.#readsByStep.set(event.runtimeStageId, reads);
98
+ },
99
+ };
100
+ }
101
+ get node() {
102
+ return this.#node;
103
+ }
104
+ /** The compiled graph's id (namespaces MCP tool names). */
105
+ get graphId() {
106
+ return this.spec.id;
107
+ }
108
+ /** The one CAS/sinceVersion cursor: total order over ALL world motion. */
109
+ get version() {
110
+ return this.#version;
111
+ }
112
+ /**
113
+ * D18 version split — `version` stays the single total-order cursor; these
114
+ * two say WHAT moved. A scrolling list must never staleness-fail a plan the
115
+ * way a closing modal must; consumers watching for re-render/replan can
116
+ * subscribe to the axis they care about.
117
+ */
118
+ get stateVersion() {
119
+ return this.#stateVersion;
120
+ }
121
+ get structureVersion() {
122
+ return this.#structureVersion;
123
+ }
124
+ /** The compiled spec every lookup goes through — NavSession overlays mount-declared tools here. */
125
+ get spec() {
126
+ return this.#spec;
127
+ }
128
+ /** The live-binding registry (protected seam for NavSession's per-instance handlers). */
129
+ get registry() {
130
+ return this.#registry;
131
+ }
132
+ /** The session's dev-warning sink (protected seam for subclass layers). */
133
+ warn(message) {
134
+ this.#warn(message);
135
+ }
136
+ // -------------------------------------------------------------------------
137
+ // Events — a PASSIVE observer surface (recorder category, session grain).
138
+ // Listeners never change what the session does; a throwing listener is
139
+ // isolated (caught + warned), never aborting the session.
140
+ // -------------------------------------------------------------------------
141
+ /** Subscribe to a session event. Returns an unsubscribe function. */
142
+ on(event, listener) {
143
+ const set = this.#listeners.get(event) ?? new Set();
144
+ set.add(listener);
145
+ this.#listeners.set(event, set);
146
+ return () => {
147
+ this.#listeners.get(event)?.delete(listener);
148
+ };
149
+ }
150
+ #emit(event, payload) {
151
+ const set = this.#listeners.get(event);
152
+ if (!set)
153
+ return;
154
+ for (const listener of set) {
155
+ try {
156
+ listener(payload);
157
+ }
158
+ catch (error) {
159
+ // Observer rule (inherited from footprintjs recorders): a listener
160
+ // error never aborts the session.
161
+ this.#warn(`hcifootprint: '${event}' listener threw: ${String(error)}`);
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * Copy a live record so a 'transition' listener (or a transitions() caller)
167
+ * can never mutate the log — INCLUDING the object-valued data channels
168
+ * (payload, produced), which are cloned defensively (fall back to the ref if
169
+ * a payload is not structured-cloneable).
170
+ */
171
+ #copyRecord(t) {
172
+ return {
173
+ ...t,
174
+ cause: { ...t.cause },
175
+ evidence: t.evidence ? t.evidence.map((c) => ({ ...c })) : undefined,
176
+ ...(t.guardUnevaluated ? { guardUnevaluated: [...t.guardUnevaluated] } : {}),
177
+ ...(t.payload !== undefined ? { payload: cloneSafe(t.payload) } : {}),
178
+ ...(t.produced !== undefined ? { produced: cloneSafe(t.produced) } : {}),
179
+ };
180
+ }
181
+ #emitTransition(record) {
182
+ this.#emit('transition', this.#copyRecord(record));
183
+ }
184
+ /** Increment the state axis and notify observers. (`+= 1` so global bump-replaces skip this.) */
185
+ #bumpState() {
186
+ this.#stateVersion += 1;
187
+ this.#emit('state', { version: this.#version, stateVersion: this.#stateVersion });
188
+ }
189
+ /** Increment the structure axis and notify observers. */
190
+ #bumpStructure() {
191
+ this.#structureVersion += 1;
192
+ this.#emit('structure', { version: this.#version, structureVersion: this.#structureVersion });
193
+ }
194
+ /** Generate an opaque group identity (never caller-supplied — see ToolGroup). */
195
+ nextGroupId(prefix = 'group') {
196
+ return `${prefix}#${(this.#groupSeq += 1)}`;
197
+ }
198
+ /**
199
+ * Flip a registered tool between clickable and greyed-out (used by the
200
+ * ToolGroup handle). A real change is world motion — it bumps the structure
201
+ * axis so a stale plan is caught and the surface re-serves.
202
+ */
203
+ setToolEnabled(affordanceId, enabled) {
204
+ if (this.#registry.setEnabled(affordanceId, enabled))
205
+ this.noteStructureChange();
206
+ }
207
+ /**
208
+ * Whether firing this tool should be refused as TOOL_DISABLED. Protected seam
209
+ * so InteractionSession can consult the INSTANCE-keyed registration first —
210
+ * a per-row disabled button ('id[instance]') must block, not just the base id.
211
+ */
212
+ isToolDisabled(affordanceId, _opts) {
213
+ return this.#registry.isEnabled(affordanceId) === false;
214
+ }
215
+ /**
216
+ * Re-baseline the coalesced structure fingerprint. A subclass whose
217
+ * structureFingerprint() override reads its OWN fields must call this once
218
+ * at the end of its constructor (the base constructor cannot: a virtual
219
+ * call there would touch subclass fields before they initialize).
220
+ */
221
+ resetStructureBaseline() {
222
+ this.#structureFingerprint = this.structureFingerprint();
223
+ }
224
+ /** Detached snapshot of the projected state (live state is immutable-after-swap; never hand out references). */
225
+ state() {
226
+ return structuredClone(this.#stateView());
227
+ }
228
+ // -------------------------------------------------------------------------
229
+ // available — the LLM's action space
230
+ // -------------------------------------------------------------------------
231
+ available() {
232
+ const flagMaterialized = this.#registry.hasAny();
233
+ const edges = [];
234
+ for (const aff of Object.values(this.spec.affordances)) {
235
+ if (!aff.on.includes(this.#node))
236
+ continue;
237
+ const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
238
+ if (!matched)
239
+ continue;
240
+ edges.push({
241
+ affordanceId: aff.id,
242
+ description: aff.description,
243
+ role: aff.role,
244
+ ...(flagMaterialized ? { materialized: this.#registry.isRegistered(aff.id) } : {}),
245
+ // A registered-but-disabled tool is served WITH the marker (a greyed
246
+ // button the agent can see), never silently hidden.
247
+ ...(this.#registry.isEnabled(aff.id) === false ? { enabled: false } : {}),
248
+ evidence: conditions,
249
+ ...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
250
+ schema: aff.schema,
251
+ highEffect: aff.highEffect,
252
+ binding: aff.binding,
253
+ ...(aff.descriptionSource === 'registration' ? { descriptionSource: 'registration' } : {}),
254
+ });
255
+ }
256
+ return { version: this.#version, node: this.#node, edges };
257
+ }
258
+ // -------------------------------------------------------------------------
259
+ // registerTools — the live-binding wire (declare statically, bind dynamically)
260
+ // -------------------------------------------------------------------------
261
+ /**
262
+ * Build a ToolGroup handle for a generated group id. Protected: the PUBLIC
263
+ * registration entry points (registerToolGroup / registerTool) live on
264
+ * InteractionSession (the tree API — they take a node path). The flat/legacy
265
+ * graph registers via {@link registerTools}. `setEnabled` may be overridden
266
+ * for instance-aware key mapping.
267
+ */
268
+ makeToolGroup(group, node, setEnabled) {
269
+ return {
270
+ id: group,
271
+ ...(node !== undefined ? { node } : {}),
272
+ setEnabled: setEnabled ?? ((toolId, enabled) => this.setToolEnabled(toolId, enabled)),
273
+ unregister: () => this.unregisterGroup(group),
274
+ };
275
+ }
276
+ /**
277
+ * Register handlers on the FLAT graph (skillGraph — no node tree). Takes a
278
+ * caller `group` string; the tree API (InteractionSession.registerToolGroup)
279
+ * is preferred where you have a node path — it returns a handle so you never
280
+ * invent a group name.
281
+ */
282
+ registerTools(opts) {
283
+ const unknown = Object.keys(opts.tools).filter((id) => !this.spec.affordances[id]);
284
+ if (unknown.length > 0) {
285
+ throw new Error(`hcifootprint: registerTools group '${opts.group}' includes undeclared affordance(s) ` +
286
+ `${unknown.map((u) => `'${u}'`).join(', ')} — declare them in the skill graph first ` +
287
+ `(known: ${Object.keys(this.spec.affordances).join(', ')}).`);
288
+ }
289
+ const triggers = {};
290
+ for (const [affordanceId, handler] of Object.entries(opts.tools)) {
291
+ this.#registry.register(opts.group, affordanceId, handler);
292
+ triggers[affordanceId] = (payload) => this.fire(affordanceId, { source: 'user', payload });
293
+ }
294
+ this.noteStructureChange();
295
+ return { triggers, unregister: () => this.unregisterGroup(opts.group) };
296
+ }
297
+ /** Remove every live binding currently owned by `group` (component unmount). */
298
+ unregisterGroup(group) {
299
+ const removed = this.#registry.unregisterGroup(group);
300
+ if (removed.length > 0)
301
+ this.noteStructureChange();
302
+ return removed;
303
+ }
304
+ /** Why an affordance is (or is not) available right now — per-condition evidence. */
305
+ explain(affordanceId) {
306
+ const aff = this.spec.affordances[affordanceId];
307
+ if (!aff) {
308
+ throw new Error(`hcifootprint: unknown affordance '${affordanceId}'. Known: ${Object.keys(this.spec.affordances).join(', ')}.`);
309
+ }
310
+ const offeredOnThisNode = aff.on.includes(this.#node);
311
+ const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
312
+ return {
313
+ affordanceId,
314
+ node: this.#node,
315
+ offeredOnThisNode,
316
+ guardPassed: matched,
317
+ available: offeredOnThisNode && matched,
318
+ evidence: conditions,
319
+ ...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
320
+ };
321
+ }
322
+ /** Skill-level disclosure for the planning LLM (descriptions + feasibility, no tool detail). */
323
+ availableSkills() {
324
+ const skills = [];
325
+ for (const skill of Object.values(this.spec.skills)) {
326
+ const pre = this.#evalGuard(skill.precondition);
327
+ const entry = this.spec.affordances[skill.steps[0]];
328
+ const entryGuard = this.#evalGuard(entry.guard);
329
+ skills.push({
330
+ id: skill.id,
331
+ description: skill.description,
332
+ steps: [...skill.steps],
333
+ preconditionPassed: pre.matched,
334
+ evidence: pre.conditions,
335
+ ...(pre.unevaluable.length > 0 ? { preconditionUnevaluable: pre.unevaluable } : {}),
336
+ entryAvailable: entry.on.includes(this.#node) && entryGuard.matched,
337
+ });
338
+ }
339
+ return { version: this.#version, node: this.#node, skills };
340
+ }
341
+ // -------------------------------------------------------------------------
342
+ // Skill frames — on-demand disclosure: serve skills, expand tools on commit
343
+ // -------------------------------------------------------------------------
344
+ /**
345
+ * Commit to a skill: opens a frame so toMCPTools()/contextBrief() serve ONLY
346
+ * that skill's currently-fireable steps plus escape tools — the token win
347
+ * (skills for planning, tools on commit). One frame at a time in v0.
348
+ */
349
+ commitSkill(skillId, opts) {
350
+ const skill = this.spec.skills[skillId];
351
+ if (!skill) {
352
+ return { ok: false, reason: 'UNKNOWN_SKILL', known: Object.keys(this.spec.skills) };
353
+ }
354
+ if (opts?.expectedVersion !== undefined && opts.expectedVersion !== this.#version) {
355
+ return { ok: false, reason: 'STALE_CURSOR', version: this.#version };
356
+ }
357
+ if (this.#frame) {
358
+ return { ok: false, reason: 'FRAME_ALREADY_OPEN', skillId: this.#frame.skillId };
359
+ }
360
+ const pre = this.#evalGuard(skill.precondition);
361
+ if (!pre.matched) {
362
+ return { ok: false, reason: 'PRECONDITION_FAILED', evidence: pre.conditions };
363
+ }
364
+ this.#frame = {
365
+ skillId,
366
+ status: 'open',
367
+ principal: opts?.source ?? 'agent',
368
+ openedAt: Date.now(),
369
+ openedAtVersion: this.#version,
370
+ firedSteps: [],
371
+ inferredSteps: [],
372
+ };
373
+ this.#version++; // the served action space just changed
374
+ this.#bumpStructure();
375
+ return { ok: true, frame: this.#frameCopy(), plan: this.skillPlan(skillId), version: this.#version };
376
+ }
377
+ /**
378
+ * Close the open frame. Default reason: 'completed' if every step was
379
+ * committed while the frame was open, else 'cancelled'. Returns the closed
380
+ * frame, or null when none was open.
381
+ */
382
+ leaveSkill(opts) {
383
+ if (!this.#frame)
384
+ return null;
385
+ const skill = this.spec.skills[this.#frame.skillId];
386
+ // Completion counts observed AND inferred steps; inferredSteps on the
387
+ // returned frame says which of them were guesses.
388
+ const allDone = skill.steps.every((step) => this.#frame.firedSteps.includes(step) || this.#frame.inferredSteps.includes(step));
389
+ this.#frame.status = opts?.reason ?? (allDone ? 'completed' : 'cancelled');
390
+ this.#frame.closedAtVersion = this.#version;
391
+ this.#frames.push(this.#frame);
392
+ const closed = this.#frameCopy(this.#frame);
393
+ this.#frame = null;
394
+ this.#version++; // back to skill-level disclosure
395
+ this.#bumpStructure();
396
+ return closed;
397
+ }
398
+ /** The open skill frame (snapshot), or null. */
399
+ skillFrame() {
400
+ return this.#frameCopy();
401
+ }
402
+ /** Frame history: every closed frame (completed / cancelled / demoted), oldest first. */
403
+ frames() {
404
+ return this.#frames.map((f) => this.#frameCopy(f));
405
+ }
406
+ /**
407
+ * The DERIVED intra-skill dependency DAG with live status. Dependencies are
408
+ * computed, never authored: step B depends on step A when A's declared
409
+ * effect.writes overlap B's guard keys — the guard×effect atoms already
410
+ * encode the ordering, so it cannot drift from the graph.
411
+ */
412
+ skillPlan(skillId) {
413
+ const skill = this.spec.skills[skillId];
414
+ if (!skill) {
415
+ throw new Error(`hcifootprint: unknown skill '${skillId}'. Known: ${Object.keys(this.spec.skills).join(', ')}.`);
416
+ }
417
+ const steps = skill.steps.map((stepId) => {
418
+ const aff = this.spec.affordances[stepId];
419
+ const dependsOn = stepDependencies(this.spec.affordances, skill.steps, stepId);
420
+ const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
421
+ const frameForSkill = this.#frame?.skillId === skillId ? this.#frame : null;
422
+ const status = frameForSkill?.firedSteps.includes(stepId)
423
+ ? 'done'
424
+ : frameForSkill?.inferredSteps.includes(stepId)
425
+ ? 'inferred-done'
426
+ : !matched
427
+ ? 'blocked'
428
+ : aff.on.includes(this.#node)
429
+ ? 'ready'
430
+ : 'off-node';
431
+ return {
432
+ affordanceId: stepId,
433
+ description: aff.description,
434
+ status,
435
+ dependsOn,
436
+ onNodes: [...aff.on],
437
+ ...(status === 'blocked' ? { blockedOn: conditions.filter((c) => !c.result) } : {}),
438
+ ...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
439
+ };
440
+ });
441
+ return { skillId, description: skill.description, steps };
442
+ }
443
+ // -------------------------------------------------------------------------
444
+ // fire — apply a transition with provenance
445
+ // -------------------------------------------------------------------------
446
+ /** Returned transition records are LIVE views — settlement updates them in place. */
447
+ fire(affordanceId, opts) {
448
+ const aff = this.spec.affordances[affordanceId];
449
+ if (!aff) {
450
+ const available = this.available().edges.map((e) => e.affordanceId);
451
+ this.recordRejection(affordanceId, 'UNKNOWN_AFFORDANCE', opts.source, undefined, available);
452
+ return { ok: false, reason: 'UNKNOWN_AFFORDANCE', available };
453
+ }
454
+ if (opts.expectedVersion !== undefined && opts.expectedVersion !== this.#version) {
455
+ this.recordRejection(affordanceId, 'STALE_CURSOR', opts.source);
456
+ return { ok: false, reason: 'STALE_CURSOR', version: this.#version };
457
+ }
458
+ if (!aff.on.includes(this.#node)) {
459
+ this.recordRejection(affordanceId, 'NOT_ON_NODE', opts.source);
460
+ return { ok: false, reason: 'NOT_ON_NODE', node: this.#node };
461
+ }
462
+ // Guards are re-evaluated at fire time — plan-time guards are advisory.
463
+ const { matched, conditions, unevaluable } = this.#evalGuard(aff.guard);
464
+ if (!matched) {
465
+ this.recordRejection(affordanceId, 'GUARD_FAILED', opts.source, conditions);
466
+ return { ok: false, reason: 'GUARD_FAILED', evidence: conditions };
467
+ }
468
+ if (aff.schema !== undefined) {
469
+ const validation = validatePayload(aff.schema, opts.payload);
470
+ if (!validation.ok) {
471
+ this.recordRejection(affordanceId, 'PAYLOAD_INVALID', opts.source);
472
+ return { ok: false, reason: 'PAYLOAD_INVALID', issues: validation.issues };
473
+ }
474
+ }
475
+ // A greyed-out button: registered but not clickable. Only blocks EXECUTION
476
+ // fires (agent/user) — the record-only DOM sensor (invoke:false) still logs
477
+ // whatever actually happened. Retriable: the app may enable it next tick.
478
+ // Instance-aware via the protected seam (a disabled repeats-row button).
479
+ if (opts.invoke !== false && this.isToolDisabled(affordanceId, opts)) {
480
+ this.recordRejection(affordanceId, 'TOOL_DISABLED', opts.source);
481
+ return { ok: false, reason: 'TOOL_DISABLED', affordanceId };
482
+ }
483
+ const record = {
484
+ id: buildRuntimeStageId(affordanceId, this.#counter.value++),
485
+ cause: { kind: 'fired', affordanceId, principal: opts.source },
486
+ timestamp: Date.now(),
487
+ payload: opts.payload,
488
+ outcome: 'pending',
489
+ evidence: conditions,
490
+ // Unevaluated conditions are taken on faith (the app is the enforcer at
491
+ // L0/L1) — the record says so instead of pretending the guard passed.
492
+ ...(unevaluable.length > 0 ? { guardUnevaluated: unevaluable } : {}),
493
+ fromNode: this.#node,
494
+ cursorVersion: this.#version,
495
+ };
496
+ this.#transitions.push(record);
497
+ this.#emitTransition(record);
498
+ this.#version++; // firing changes the world the next plan must see
499
+ const declaredWrites = aff.effect?.writes ?? [];
500
+ if (declaredWrites.length > 0 && this.#stateTap) {
501
+ // The app owns the real handler; the delta arrives via updateState().
502
+ this.#pending.push({ record, affordance: aff });
503
+ this.#invokeHandler(record, affordanceId, opts);
504
+ return { ok: true, transition: record, version: this.#version, settlement: 'awaiting-state' };
505
+ }
506
+ if (declaredWrites.length > 0) {
507
+ // No state tap: nothing will ever report a delta. A registered handler
508
+ // settles this record on ITS completion; with nothing to execute, settle
509
+ // now. Either way effectVerified is honestly 'unobservable'.
510
+ const willExecute = opts.invoke !== false && this.handlerFor(affordanceId, opts) !== undefined;
511
+ if (willExecute) {
512
+ this.#pending.push({ record, affordance: aff, settleOnCompletion: true });
513
+ this.#invokeHandler(record, affordanceId, opts);
514
+ return { ok: true, transition: record, version: this.#version, settlement: 'awaiting-state' };
515
+ }
516
+ this.#settle(record, aff, {}, { forceUnobservable: true });
517
+ this.#invokeHandler(record, affordanceId, opts);
518
+ return { ok: true, transition: record, version: this.#version, settlement: 'settled' };
519
+ }
520
+ this.#settle(record, aff, {});
521
+ this.#invokeHandler(record, affordanceId, opts);
522
+ return { ok: true, transition: record, version: this.#version, settlement: 'settled' };
523
+ }
524
+ /**
525
+ * D13: fire() executes when a live binding exists. Fire-and-forget — the
526
+ * app's state tap reports the real delta as usual; a throwing handler
527
+ * auto-rejects its still-pending transition (or rolls back an
528
+ * already-committed immediate settle) instead of leaving a lie in the log.
529
+ *
530
+ * Attribution safety: while the handler's synchronous portion runs,
531
+ * updateState() attributes to THIS record directly; while the handler is in
532
+ * flight (or failed), bare-FIFO skips this record so a neighbor's report
533
+ * can never steal it.
534
+ */
535
+ #invokeHandler(record, affordanceId, opts) {
536
+ if (opts.invoke === false)
537
+ return; // record-only (the DOM sensor's mode)
538
+ const handler = this.handlerFor(affordanceId, opts);
539
+ if (!handler)
540
+ return;
541
+ const pendingEntry = this.#pending.find((p) => p.record.id === record.id);
542
+ if (pendingEntry)
543
+ pendingEntry.handlerInFlight = true;
544
+ void Promise.resolve()
545
+ .then(() => {
546
+ this.#invokingRecordId = record.id;
547
+ try {
548
+ return handler(opts.payload);
549
+ }
550
+ finally {
551
+ this.#invokingRecordId = null;
552
+ }
553
+ })
554
+ .then((returnValue) => {
555
+ // Act → get data back: whatever the handler returned (search results, a
556
+ // looked-up record) rides the DATA channel on the record — sanitized +
557
+ // capped so untrusted content can never become planner instructions.
558
+ if (this.#captureProduced && returnValue !== undefined && returnValue !== null) {
559
+ record.produced = sanitizeProduced(returnValue);
560
+ }
561
+ const entry = this.#pending.find((p) => p.record.id === record.id);
562
+ if (!entry)
563
+ return;
564
+ if (entry.settleOnCompletion) {
565
+ // Tapless session: the handler finishing IS the settlement signal.
566
+ this.#pending.splice(this.#pending.indexOf(entry), 1);
567
+ this.#settle(entry.record, entry.affordance, {}, { forceUnobservable: true });
568
+ return;
569
+ }
570
+ entry.handlerInFlight = false; // async app: the tap's later report may FIFO-settle it
571
+ })
572
+ .catch((error) => {
573
+ const index = this.#pending.findIndex((p) => p.record.id === record.id);
574
+ if (index >= 0) {
575
+ // Effect never landed: reject the pending so later deltas are not mis-attributed.
576
+ this.#pending.splice(index, 1);
577
+ record.outcome = 'rejected';
578
+ this.#version++;
579
+ this.#emitTransition(record); // observers see the settled (rejected) occurrence
580
+ }
581
+ else if (record.outcome === 'committed' && record.effectVerified === 'unobservable') {
582
+ // Immediate/tapless settle committed BEFORE the handler ran and the
583
+ // handler failed: the commit was a claim about an action that never
584
+ // happened. Roll it back and, if the settle moved the cursor on the
585
+ // navigation CLAIM, walk the cursor back honestly. A commit backed by
586
+ // REAL evidence (a state report settled it, effectVerified true) is
587
+ // stronger than the handler's failure — that one stands.
588
+ record.outcome = 'rolled-back';
589
+ this.#version++;
590
+ this.#emitTransition(record); // observers see the rolled-back occurrence
591
+ if (record.toNodeClaimed && record.toNode === this.#node && record.fromNode !== this.#node) {
592
+ this.sync(record.fromNode, { stimulus: 'navigation', principal: 'system' });
593
+ }
594
+ }
595
+ this.#warn(`hcifootprint: handler for '${affordanceId}' threw: ${String(error)}`);
596
+ });
597
+ }
598
+ // -------------------------------------------------------------------------
599
+ // updateState — the app reports reality; pending transitions settle
600
+ // -------------------------------------------------------------------------
601
+ /**
602
+ * Report a projected-state delta from the app (router/store tap).
603
+ *
604
+ * Attribution, in priority order:
605
+ * 1. `opts.transitionId` — settles that pending transition precisely (preferred).
606
+ * 2. `opts.stimulus`/`opts.principal` set — recorded as a stimulus transition,
607
+ * NEVER attributed to a pending fire (explicit attribution wins; a server
608
+ * push must not hijack a pending action's provenance).
609
+ * 3. Otherwise: FIFO to the oldest pending fired transition. With several
610
+ * pendings and out-of-order app handlers this can mis-attribute — pass
611
+ * transitionId from your tap when you can; effectVerified=false is the
612
+ * designed detector for key mismatches.
613
+ * 4. No pendings, no hints: recorded as a `stimulus:'unknown'` transition —
614
+ * state never moves silently.
615
+ *
616
+ * Undefined-valued entries are dropped from the report before anything else
617
+ * (uniformly — new and existing keys): a report cannot store undefined, and
618
+ * a declared write reported as undefined counts as missing
619
+ * (`effectVerified: false`).
620
+ */
621
+ updateState(delta, opts) {
622
+ // Uniform undefined semantics: entries whose value is undefined are
623
+ // DROPPED from the report — new and existing keys alike, on every
624
+ // attribution path. Before this rule, a NEW key with undefined was
625
+ // dropped while an EXISTING key STORED undefined — and a stored
626
+ // undefined slips through value guards (`ne ''` matches undefined),
627
+ // which let a wrong-payload handler put a null item in a cart while
628
+ // the "item selected" guard passed. A JSON tap cannot even express
629
+ // undefined; from in-process handlers it is always an accident.
630
+ // Consequence: a declared write reported as undefined is a MISSING
631
+ // write — effectVerified flips false, the designed drift detector.
632
+ delta = Object.fromEntries(Object.entries(delta).filter(([, value]) => value !== undefined));
633
+ // Validate BEFORE consuming a pending: a non-cloneable value (function, DOM
634
+ // node) must reject loudly without destroying the attribution queue.
635
+ try {
636
+ structuredClone(delta);
637
+ }
638
+ catch (error) {
639
+ return { ok: false, reason: 'UNCLONEABLE_DELTA', issues: String(error) };
640
+ }
641
+ if (opts?.transitionId !== undefined) {
642
+ const index = this.#pending.findIndex((p) => p.record.id === opts.transitionId);
643
+ if (index < 0) {
644
+ return {
645
+ ok: false,
646
+ reason: 'UNKNOWN_TRANSITION',
647
+ pending: this.#pending.map((p) => p.record.id),
648
+ };
649
+ }
650
+ const [pending] = this.#pending.splice(index, 1);
651
+ this.#settle(pending.record, pending.affordance, delta);
652
+ return { ok: true, attributed: true, transition: pending.record, version: this.#version };
653
+ }
654
+ const explicitStimulus = opts?.stimulus !== undefined || opts?.principal !== undefined;
655
+ // A handler reporting synchronously from inside its own invocation settles
656
+ // its OWN record — precise attribution, immune to the burst-fire race.
657
+ if (!explicitStimulus && this.#invokingRecordId !== null) {
658
+ const index = this.#pending.findIndex((p) => p.record.id === this.#invokingRecordId);
659
+ if (index >= 0) {
660
+ const [pending] = this.#pending.splice(index, 1);
661
+ this.#settle(pending.record, pending.affordance, delta);
662
+ return { ok: true, attributed: true, transition: pending.record, version: this.#version };
663
+ }
664
+ }
665
+ if (!explicitStimulus && this.#pending.length > 0) {
666
+ // Bare FIFO skips records whose handler is still in flight — the handler
667
+ // has first claim on its own record (see #invokeHandler).
668
+ const index = this.#pending.findIndex((p) => !p.handlerInFlight);
669
+ if (index >= 0) {
670
+ const pending = this.#pending[index];
671
+ this.#settle(pending.record, pending.affordance, delta);
672
+ this.#pending.splice(index, 1);
673
+ return { ok: true, attributed: true, transition: pending.record, version: this.#version };
674
+ }
675
+ // Every pending is handler-in-flight. If the delta covers exactly ONE
676
+ // in-flight pending's declared writes, it is that handler's own report
677
+ // (arriving from its async portion, past the #invokingRecordId window) —
678
+ // settle THAT record precisely instead of stranding it forever.
679
+ const deltaKeys = Object.keys(delta);
680
+ const own = this.#pending.filter((p) => {
681
+ const writes = p.affordance.effect?.writes ?? [];
682
+ return writes.length > 0 && writes.every((key) => deltaKeys.includes(key));
683
+ });
684
+ if (own.length === 1) {
685
+ const pending = own[0];
686
+ this.#settle(pending.record, pending.affordance, delta);
687
+ this.#pending.splice(this.#pending.indexOf(pending), 1);
688
+ return { ok: true, attributed: true, transition: pending.record, version: this.#version };
689
+ }
690
+ // Ambiguous or non-matching: fall through to stimulus (never inference —
691
+ // guessing while fires are in flight fabricates duplicates).
692
+ }
693
+ // Tier-2 effect-signature inference — only with NO hints and NO pendings.
694
+ // The no-pendings condition is load-bearing: with an async handler still in
695
+ // flight, ITS own report would otherwise match its affordance's signature
696
+ // and fabricate a duplicate inferred transition while the real pending
697
+ // starves. In-flight world = wait for the pending machinery; guessing is
698
+ // for quiet moments only.
699
+ if (!explicitStimulus && this.#pending.length === 0) {
700
+ const inferred = this.#inferAffordanceForDelta(Object.keys(delta));
701
+ if (inferred) {
702
+ const guardEval = this.#evalGuard(inferred.guard);
703
+ const record = {
704
+ id: buildRuntimeStageId(inferred.id, this.#counter.value++),
705
+ cause: { kind: 'fired', affordanceId: inferred.id, principal: 'unknown', inferred: true },
706
+ timestamp: Date.now(),
707
+ outcome: 'pending',
708
+ evidence: guardEval.conditions,
709
+ ...(guardEval.unevaluable.length > 0 ? { guardUnevaluated: guardEval.unevaluable } : {}),
710
+ fromNode: this.#node,
711
+ cursorVersion: this.#version,
712
+ };
713
+ this.#commitDelta(inferred.id, record.id, Object.keys(inferred.guard ?? {}), delta);
714
+ record.outcome = 'committed';
715
+ record.toNode = this.#node; // inference never moves the cursor — that would be guessing twice
716
+ record.effectVerified = true; // writes ⊆ delta by construction of the match
717
+ this.#transitions.push(record);
718
+ this.#emitTransition(record);
719
+ this.#version++;
720
+ this.#bumpState();
721
+ // A guessed completion never advances firedSteps, but it must be VISIBLE
722
+ // to the plan — 'inferred-done' — or the agent blind-refires the step.
723
+ if (this.#frame &&
724
+ this.spec.skills[this.#frame.skillId].steps.includes(inferred.id) &&
725
+ !this.#frame.firedSteps.includes(inferred.id) &&
726
+ !this.#frame.inferredSteps.includes(inferred.id)) {
727
+ this.#frame.inferredSteps.push(inferred.id);
728
+ }
729
+ this.#checkFrameAfterWorldChange();
730
+ return { ok: true, attributed: false, transition: record, version: this.#version };
731
+ }
732
+ }
733
+ const stimulus = opts?.stimulus ?? 'unknown';
734
+ const record = {
735
+ id: buildRuntimeStageId(`stimulus:${stimulus}`, this.#counter.value++),
736
+ cause: { kind: 'stimulus', stimulus, principal: opts?.principal ?? 'system' },
737
+ timestamp: Date.now(),
738
+ outcome: 'pending',
739
+ fromNode: this.#node,
740
+ cursorVersion: this.#version,
741
+ };
742
+ // No tracked reads: the causal layer will honestly flag untracked sources.
743
+ this.#commitDelta(`stimulus:${stimulus}`, record.id, [], delta);
744
+ record.outcome = 'committed';
745
+ record.toNode = this.#node;
746
+ record.effectVerified = 'unobservable';
747
+ this.#transitions.push(record);
748
+ this.#emitTransition(record);
749
+ this.#version++;
750
+ if (Object.keys(delta).length > 0)
751
+ this.#bumpState();
752
+ this.#checkFrameAfterWorldChange();
753
+ return { ok: true, attributed: false, transition: record, version: this.#version };
754
+ }
755
+ /** Exactly-one match rule: ambiguity refuses to guess (falls through to stimulus). */
756
+ #inferAffordanceForDelta(deltaKeys) {
757
+ const candidates = Object.values(this.spec.affordances).filter((aff) => {
758
+ if (!this.#registry.isRegistered(aff.id))
759
+ return false;
760
+ if (!aff.on.includes(this.#node))
761
+ return false;
762
+ const writes = aff.effect?.writes ?? [];
763
+ if (writes.length === 0)
764
+ return false;
765
+ if (!writes.every((key) => deltaKeys.includes(key)))
766
+ return false;
767
+ return this.#evalGuard(aff.guard).matched;
768
+ });
769
+ return candidates.length === 1 ? candidates[0] : null;
770
+ }
771
+ /** Fired transitions still awaiting their state report (oldest first). */
772
+ pending() {
773
+ return this.#pending.map((p) => ({
774
+ id: p.record.id,
775
+ affordanceId: p.affordance.id,
776
+ firedAt: p.record.timestamp,
777
+ }));
778
+ }
779
+ /**
780
+ * The app rejected/rolled back a transition's effect (optimistic UI).
781
+ * Works on a PENDING transition (effect never landed → no bundle) and on an
782
+ * already-SETTLED one (server rejected after the optimistic report): the
783
+ * record flips to rolled-back and the app's compensating delta should follow
784
+ * via updateState — the commit log keeps both writes, honestly.
785
+ */
786
+ reject(transitionId, opts) {
787
+ const index = this.#pending.findIndex((p) => p.record.id === transitionId);
788
+ if (index >= 0) {
789
+ const [pending] = this.#pending.splice(index, 1);
790
+ pending.record.outcome = opts?.outcome ?? 'rejected';
791
+ this.#version++;
792
+ this.#emitTransition(pending.record);
793
+ return pending.record;
794
+ }
795
+ const settled = this.#transitions.find((t) => t.id === transitionId && t.outcome === 'committed');
796
+ if (settled) {
797
+ settled.outcome = opts?.outcome ?? 'rolled-back';
798
+ this.#version++;
799
+ this.#emitTransition(settled);
800
+ return settled;
801
+ }
802
+ throw new Error(`hcifootprint: no pending or committed transition '${transitionId}' to reject.`);
803
+ }
804
+ // -------------------------------------------------------------------------
805
+ // sync — reconcile the cursor when the world moved without an offered edge
806
+ // -------------------------------------------------------------------------
807
+ /**
808
+ * The observed node is runtime input from the world, so an unauthored page
809
+ * is NOT an error: the cursor follows reality (off-graph), available()
810
+ * honestly serves zero edges there, and the hop is still recorded.
811
+ */
812
+ sync(observedNode, opts) {
813
+ if (observedNode === this.#node) {
814
+ return { changed: false, node: this.#node, version: this.#version };
815
+ }
816
+ const offGraph = !this.spec.pages[observedNode];
817
+ const record = {
818
+ id: buildRuntimeStageId(`sync:${observedNode}`, this.#counter.value++),
819
+ cause: {
820
+ kind: 'stimulus',
821
+ stimulus: opts?.stimulus ?? 'navigation',
822
+ principal: opts?.principal ?? 'system',
823
+ },
824
+ timestamp: Date.now(),
825
+ outcome: 'committed',
826
+ effectVerified: 'unobservable',
827
+ unverifiedEdge: true, // this hop passed no guard — slices treat it as inferred
828
+ fromNode: this.#node,
829
+ toNode: observedNode,
830
+ cursorVersion: this.#version,
831
+ };
832
+ // Empty commit — footprint's own idiom: empty commits are deliberate cursor stops.
833
+ this.#commitDelta(`sync:${observedNode}`, record.id, [], {});
834
+ this.#node = observedNode;
835
+ this.#transitions.push(record);
836
+ this.#emitTransition(record);
837
+ this.#version++;
838
+ this.#checkFrameAfterWorldChange();
839
+ return offGraph
840
+ ? { changed: true, transition: record, node: this.#node, version: this.#version, offGraph: true }
841
+ : { changed: true, transition: record, node: this.#node, version: this.#version };
842
+ }
843
+ // -------------------------------------------------------------------------
844
+ // Trace surface — footprint's post-hoc toolchain over this session
845
+ // -------------------------------------------------------------------------
846
+ /** The footprintjs commit log: one bundle per SETTLED/stimulus/sync transition. */
847
+ commitLog() {
848
+ return [...this.#log.list()];
849
+ }
850
+ /**
851
+ * The interaction log. Settled/stimulus/sync rows join 1:1 to commitLog()
852
+ * bundles by TransitionRecord.id; pending and rejected/rolled-back rows
853
+ * exist only here (their effects never touched state). Rows are snapshots —
854
+ * live records are the ones returned by fire()/updateState()/reject().
855
+ */
856
+ transitions() {
857
+ return this.#transitions.map((t) => this.#copyRecord(t));
858
+ }
859
+ /** "Why does this state key hold its value?" — footprint backward slice, formatted. */
860
+ why(key) {
861
+ const slice = sliceForKey(this.#log.list(), key, keysReadFromMap(this.#readsByStep));
862
+ return formatSlice(slice);
863
+ }
864
+ /** runtimeStageId → tracked read keys (feed to causalChain's keysRead lookup). */
865
+ readsByStep() {
866
+ return this.#readsByStep;
867
+ }
868
+ /**
869
+ * Data the given transition's handler RETURNED (search results, a looked-up
870
+ * record) — a fresh snapshot, safe to serialize into a tool result. Available
871
+ * once the handler has resolved, so read it AFTER awaiting the settlement.
872
+ * Returns undefined when the handler returned nothing (or capture is off).
873
+ */
874
+ producedFor(transitionId) {
875
+ const record = this.#transitions.find((t) => t.id === transitionId);
876
+ if (record?.produced === undefined)
877
+ return undefined;
878
+ return sanitizeProduced(record.produced); // fresh copy — consumer mutation must not touch the record
879
+ }
880
+ // -------------------------------------------------------------------------
881
+ // Gap ledger — unmet demand, the input to "which skill should we build next"
882
+ // -------------------------------------------------------------------------
883
+ /**
884
+ * Report an ask that no available action or skill could serve (typically
885
+ * called by the agent's report_gap tool before it apologizes). The row is
886
+ * token-lean by design: the ask plus NAME lists, never descriptions.
887
+ */
888
+ reportGap(opts) {
889
+ const row = {
890
+ kind: 'reported',
891
+ timestamp: Date.now(),
892
+ node: this.#node,
893
+ version: this.#version,
894
+ ...this.#gapContext(),
895
+ request: opts.request.slice(0, 500),
896
+ reason: opts.reason ?? 'other',
897
+ ...(opts.note !== undefined ? { note: opts.note.slice(0, 500) } : {}),
898
+ ...(opts.principal !== undefined ? { principal: opts.principal } : {}),
899
+ };
900
+ this.#pushGap(row);
901
+ return structuredClone(row);
902
+ }
903
+ /** The unmet-demand ledger (DEEP copies) — export it to your analytics/triage pipeline. */
904
+ gaps() {
905
+ return this.#gaps.map((g) => structuredClone(g));
906
+ }
907
+ /** Live export hook: fires once per new gap row. Sugar for `on('gap', …)`. */
908
+ onGap(listener) {
909
+ return this.on('gap', listener);
910
+ }
911
+ /** Every refused fire becomes a gap-ledger row (protected: NavSession adds tree rejections). */
912
+ recordRejection(affordanceId, rejectionReason, principal, evidence, precomputedActions) {
913
+ this.#pushGap({
914
+ kind: 'fire-rejected',
915
+ timestamp: Date.now(),
916
+ node: this.#node,
917
+ version: this.#version,
918
+ availableActions: precomputedActions ?? this.available().edges.map((e) => e.affordanceId),
919
+ availableSkills: Object.keys(this.spec.skills),
920
+ affordanceId,
921
+ rejectionReason,
922
+ principal,
923
+ // Copy the CONDITION OBJECTS too — the same objects ride FireResult.evidence,
924
+ // and a caller annotating those must not rewrite the ledger.
925
+ ...(evidence !== undefined ? { evidence: evidence.map((c) => ({ ...c })) } : {}),
926
+ });
927
+ }
928
+ /** Names only — token-lean and injection-safe context for triage. */
929
+ #gapContext() {
930
+ return {
931
+ availableActions: this.available().edges.map((e) => e.affordanceId),
932
+ availableSkills: Object.keys(this.spec.skills),
933
+ };
934
+ }
935
+ #pushGap(row) {
936
+ this.#gaps.push(row);
937
+ // Per-listener deep copy: exporter mutation must never touch the ledger,
938
+ // nor another listener's view. Routes through the 'gap' observer channel.
939
+ const set = this.#listeners.get('gap');
940
+ if (!set)
941
+ return;
942
+ for (const listener of set) {
943
+ try {
944
+ listener(structuredClone(row));
945
+ }
946
+ catch (error) {
947
+ // Consumer export code must never break the session (recorder rule).
948
+ this.#warn(`hcifootprint: gap listener threw: ${String(error)}`);
949
+ }
950
+ }
951
+ }
952
+ /**
953
+ * Per-edge MCP tool descriptors for the CURRENT slice. Regenerated per call
954
+ * — never cached. With a skill frame open, serves ONLY the frame's
955
+ * currently-fireable steps + escape tools (authored cancel/back roles and a
956
+ * synthetic leave-skill) — the on-demand disclosure contract.
957
+ */
958
+ toMCPTools(opts) {
959
+ const served = this.#servedEdges();
960
+ const tools = edgesToMCPTools(this.spec, served.edges, opts);
961
+ if (served.escape)
962
+ tools.push(leaveSkillTool(this.spec, this.#frame.skillId));
963
+ return tools;
964
+ }
965
+ /**
966
+ * Token-lean, prompt-ready session context for the next chat turn: current
967
+ * position, the open frame, and who did what since `sinceVersion` (the
968
+ * agent's last look). Built from AUTHORED strings and structural facts only
969
+ * — state values and payloads never enter the text.
970
+ */
971
+ contextBrief(opts) {
972
+ const sinceVersion = opts?.sinceVersion;
973
+ const max = opts?.maxTransitions ?? 20;
974
+ const relevant = this.#transitions.filter((t) => sinceVersion === undefined || t.cursorVersion >= sinceVersion);
975
+ const omitted = Math.max(0, relevant.length - max);
976
+ const shown = relevant.slice(-max);
977
+ const changedKeysById = new Map(this.#log
978
+ .list()
979
+ .map((b) => [b.runtimeStageId, Object.keys({ ...(b.overwrite ?? {}), ...(b.updates ?? {}) })]));
980
+ const lines = [`You are on: ${this.#nodeLabel(this.#node)}.`];
981
+ if (this.#frame) {
982
+ const skill = this.spec.skills[this.#frame.skillId];
983
+ lines.push(`Open skill: ${this.#frame.skillId} — ${skill.description} ` +
984
+ `(${this.#frame.firedSteps.length}/${skill.steps.length} steps done).`);
985
+ }
986
+ for (const f of this.#frames) {
987
+ if (f.status !== 'demoted')
988
+ continue;
989
+ if (sinceVersion !== undefined && (f.closedAtVersion ?? 0) < sinceVersion)
990
+ continue;
991
+ lines.push(`Note: skill ${f.skillId} was demoted — its precondition no longer holds.`);
992
+ }
993
+ lines.push(sinceVersion !== undefined
994
+ ? `Since version ${sinceVersion} (now ${this.#version}):`
995
+ : `Session so far (version ${this.#version}):`);
996
+ if (shown.length === 0)
997
+ lines.push(' (no actions)');
998
+ if (omitted > 0)
999
+ lines.push(` … ${omitted} earlier action(s) omitted.`);
1000
+ for (const t of shown)
1001
+ lines.push(` • ${this.#briefLine(t, changedKeysById)}`);
1002
+ const pend = this.pending();
1003
+ lines.push(pend.length
1004
+ ? `Pending (awaiting app state): ${pend.map((p) => p.affordanceId).join(', ')}.`
1005
+ : 'Pending: none.');
1006
+ const served = this.#servedEdges();
1007
+ const names = served.edges.map((e) => e.affordanceId + (e.highEffect ? ' [high-effect]' : ''));
1008
+ if (served.escape)
1009
+ names.push('leave-skill');
1010
+ lines.push(`Available now: ${names.length > 0 ? names.join(', ') : '(nothing on this page)'}.`);
1011
+ return { node: this.#node, version: this.#version, frame: this.#frameCopy(), text: lines.join('\n') };
1012
+ }
1013
+ // -------------------------------------------------------------------------
1014
+ // Internals
1015
+ // -------------------------------------------------------------------------
1016
+ #stateView() {
1017
+ return (this.#heap.getState() ?? {});
1018
+ }
1019
+ /**
1020
+ * Resolve the live handler for a fire. Protected seam: NavSession keys
1021
+ * repeats-container handlers by instance ('cancel-order[o-123]').
1022
+ */
1023
+ handlerFor(affordanceId, _opts) {
1024
+ return this.#registry.handlerFor(affordanceId);
1025
+ }
1026
+ /**
1027
+ * D18: registration/presence flips ARE world motion — but coalesced. Raw
1028
+ * registry edits apply immediately; the trace row + version bump flush once
1029
+ * per microtask, and a leave+enter of the same shape within one window
1030
+ * cancels to nothing (StrictMode double-mounts and HMR never pollute the
1031
+ * trace). This also fixes the verified v1 gap: registerTools never bumped
1032
+ * the version, so a plan made before a mount/unmount passed CAS after it.
1033
+ */
1034
+ noteStructureChange() {
1035
+ if (this.#structureFlushScheduled)
1036
+ return;
1037
+ this.#structureFlushScheduled = true;
1038
+ queueMicrotask(() => {
1039
+ this.#structureFlushScheduled = false;
1040
+ const now = this.structureFingerprint();
1041
+ if (now === this.#structureFingerprint)
1042
+ return; // net-zero churn: no row, no bump
1043
+ this.#structureFingerprint = now;
1044
+ const record = {
1045
+ id: buildRuntimeStageId('stimulus:structure-swap', this.#counter.value++),
1046
+ cause: { kind: 'stimulus', stimulus: 'structure-swap', principal: 'system' },
1047
+ timestamp: Date.now(),
1048
+ outcome: 'committed',
1049
+ effectVerified: 'unobservable',
1050
+ fromNode: this.#node,
1051
+ toNode: this.#node,
1052
+ cursorVersion: this.#version,
1053
+ };
1054
+ // Empty commit — footprint's deliberate-cursor-stop idiom.
1055
+ this.#commitDelta('stimulus:structure-swap', record.id, [], {});
1056
+ this.#transitions.push(record);
1057
+ this.#emitTransition(record);
1058
+ this.#version++;
1059
+ this.#bumpStructure();
1060
+ this.#checkFrameAfterWorldChange();
1061
+ });
1062
+ }
1063
+ /**
1064
+ * What "the served structure" looks like right now — compared at flush time
1065
+ * against the last flushed value. NavSession extends this with the presence
1066
+ * set and visibility signals.
1067
+ */
1068
+ structureFingerprint() {
1069
+ // Include enabled state so a setEnabled() flip is world motion (the served
1070
+ // surface changed), just like a mount/unmount.
1071
+ return this.#registry
1072
+ .registrations()
1073
+ .map((r) => r.affordanceId + (r.enabled ? '' : ':off'))
1074
+ .sort()
1075
+ .join('|');
1076
+ }
1077
+ /**
1078
+ * Guard evaluation with the D18 honesty split: conditions over keys the
1079
+ * state view has never contained are UNEVALUABLE, not false. Evaluable
1080
+ * conditions decide matched; unevaluable keys are returned as a marker so
1081
+ * edges are served-with-honesty instead of silently hidden — the rung-killer
1082
+ * fix that lets one authored graph work at every rung of the ladder.
1083
+ */
1084
+ #evalGuard(guard) {
1085
+ if (!guard)
1086
+ return { matched: true, conditions: [], unevaluable: [] };
1087
+ const state = this.#stateView();
1088
+ // A key holding undefined is as unevaluable as an absent one: operators
1089
+ // like `ne ''` would MATCH undefined, so a guard authored to mean "a
1090
+ // value is set" would pass exactly when it is not. Honesty over guessing:
1091
+ // serve the edge with the guardUnevaluated marker instead. (updateState
1092
+ // drops undefined from reports; this also covers undefined handed in via
1093
+ // the initial state.)
1094
+ const evaluableKey = (key) => state[key] !== undefined;
1095
+ const unevaluable = Object.keys(guard).filter((key) => !evaluableKey(key));
1096
+ if (unevaluable.length === 0) {
1097
+ const { matched, conditions } = evaluateFilter((key) => state[key], (key) => this.#redacted.has(key), guard);
1098
+ return { matched, conditions, unevaluable };
1099
+ }
1100
+ const evaluable = Object.fromEntries(Object.entries(guard).filter(([key]) => evaluableKey(key)));
1101
+ // evaluateFilter deliberately never matches {} — an all-unevaluable guard
1102
+ // must not fall into that anti-vacuous-truth rule, so short-circuit.
1103
+ if (Object.keys(evaluable).length === 0)
1104
+ return { matched: true, conditions: [], unevaluable };
1105
+ const { matched, conditions } = evaluateFilter((key) => state[key], (key) => this.#redacted.has(key), evaluable);
1106
+ return { matched, conditions, unevaluable };
1107
+ }
1108
+ #settle(record, aff, delta, settleOpts) {
1109
+ // Read provenance = the guard keys this transition's availability rested on.
1110
+ this.#commitDelta(aff.id, record.id, Object.keys(aff.guard ?? {}), delta);
1111
+ const deltaKeys = Object.keys(delta);
1112
+ const declared = aff.effect?.writes;
1113
+ record.effectVerified = settleOpts?.forceUnobservable
1114
+ ? 'unobservable' // tapless settlement: no report will ever exist to check against
1115
+ : declared && declared.length > 0
1116
+ ? declared.every((key) => deltaKeys.includes(key))
1117
+ : 'unobservable';
1118
+ if (aff.effect?.navigatesTo) {
1119
+ // Declared target = expectation, flagged as a CLAIM; sync() records reality.
1120
+ record.toNode = aff.effect.navigatesTo;
1121
+ record.toNodeClaimed = true;
1122
+ // The claim moves the LIVE cursor only if nothing else moved it since
1123
+ // this transition fired — a weaker claim must never clobber a newer
1124
+ // sync() observation that interleaved while the fire was pending.
1125
+ if (this.#node === record.fromNode)
1126
+ this.#node = aff.effect.navigatesTo;
1127
+ }
1128
+ else {
1129
+ // A non-navigating affordance never moves the record's cursor — even if
1130
+ // an interleaved sync() moved the session's.
1131
+ record.toNode = record.fromNode;
1132
+ }
1133
+ record.outcome = 'committed';
1134
+ this.#version++;
1135
+ if (deltaKeys.length > 0)
1136
+ this.#bumpState(); // empty settles are cursor stops, not state motion
1137
+ if (this.#frame &&
1138
+ this.spec.skills[this.#frame.skillId].steps.includes(aff.id) &&
1139
+ !this.#frame.firedSteps.includes(aff.id)) {
1140
+ this.#frame.firedSteps.push(aff.id);
1141
+ }
1142
+ this.#emitTransition(record); // now committed — observers see the settled record
1143
+ this.#checkFrameAfterWorldChange();
1144
+ }
1145
+ /** The disclosure filter: full slice normally; frame steps + escape roles when a frame is open. */
1146
+ #servedEdges() {
1147
+ const edges = this.available().edges;
1148
+ if (!this.#frame)
1149
+ return { edges, escape: false };
1150
+ const steps = this.spec.skills[this.#frame.skillId].steps;
1151
+ return {
1152
+ edges: edges.filter((e) => steps.includes(e.affordanceId) || e.role === 'cancel' || e.role === 'back'),
1153
+ escape: true,
1154
+ };
1155
+ }
1156
+ /**
1157
+ * Demotion: after any world change, an open frame whose skill PRECONDITION
1158
+ * no longer holds is closed as 'demoted' — the served context re-collapses
1159
+ * to skill level and the agent replans. Step guards failing is normal DAG
1160
+ * progress and never demotes; skills without a precondition never demote.
1161
+ */
1162
+ #checkFrameAfterWorldChange() {
1163
+ if (!this.#frame)
1164
+ return;
1165
+ const skill = this.spec.skills[this.#frame.skillId];
1166
+ if (!skill.precondition)
1167
+ return;
1168
+ if (this.#evalGuard(skill.precondition).matched)
1169
+ return;
1170
+ this.#frame.status = 'demoted';
1171
+ this.#frame.closedAtVersion = this.#version;
1172
+ this.#frames.push(this.#frame);
1173
+ this.#frame = null;
1174
+ this.#version++;
1175
+ this.#bumpStructure();
1176
+ }
1177
+ #frameCopy(frame = this.#frame) {
1178
+ return frame
1179
+ ? { ...frame, firedSteps: [...frame.firedSteps], inferredSteps: [...frame.inferredSteps] }
1180
+ : null;
1181
+ }
1182
+ /**
1183
+ * The brief's TEXT channel only carries authored strings. A page id is
1184
+ * authored; an OFF-GRAPH observed node name is runtime router text (an
1185
+ * attacker-influencable URL segment) — it renders as a constant label here
1186
+ * and stays available verbatim only in structured data fields.
1187
+ */
1188
+ #nodeLabel(name) {
1189
+ return Object.hasOwn(this.spec.pages, name) ? name : '(an unmapped location, off the authored graph)';
1190
+ }
1191
+ /** One authored-strings-only line per transition for contextBrief(). */
1192
+ #briefLine(t, changedKeysById) {
1193
+ if (t.cause.kind === 'fired') {
1194
+ const aff = this.spec.affordances[t.cause.affordanceId ?? ''];
1195
+ const moved = t.toNode && t.toNode !== t.fromNode
1196
+ ? ` (${this.#nodeLabel(t.fromNode)} → ${this.#nodeLabel(t.toNode)})`
1197
+ : '';
1198
+ const flags = [];
1199
+ if (t.cause.inferred)
1200
+ flags.push('inferred, not observed');
1201
+ if (aff?.highEffect)
1202
+ flags.push('high-effect');
1203
+ if (t.toNodeClaimed)
1204
+ flags.push('navigation claimed, unconfirmed');
1205
+ if (t.outcome === 'pending')
1206
+ flags.push('awaiting app state');
1207
+ if (t.outcome === 'rejected' || t.outcome === 'rolled-back' || t.outcome === 'superseded') {
1208
+ flags.push(t.outcome);
1209
+ }
1210
+ if (t.effectVerified === false)
1211
+ flags.push('declared effect not observed');
1212
+ const suffix = flags.length > 0 ? ` [${flags.join('; ')}]` : '';
1213
+ return `${t.cause.principal} fired ${t.cause.affordanceId} — ${aff?.description ?? ''}${moved}${suffix}`;
1214
+ }
1215
+ if (t.toNode && t.toNode !== t.fromNode) {
1216
+ return `${t.cause.principal} ${t.cause.stimulus}: cursor moved ${this.#nodeLabel(t.fromNode)} → ${this.#nodeLabel(t.toNode)} (unverified edge)`;
1217
+ }
1218
+ if (t.cause.stimulus === 'structure-swap') {
1219
+ return 'the served tool surface changed (something mounted, unmounted, or changed visibility)';
1220
+ }
1221
+ // Key NAMES are the designed disclosure (values never enter text) — but a
1222
+ // tap could relay hostile keys, so they are hardened before rendering.
1223
+ const keys = (changedKeysById.get(t.id) ?? []).map(
1224
+ // eslint-disable-next-line no-control-regex
1225
+ (key) => key.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 60));
1226
+ return `${t.cause.principal} ${t.cause.stimulus} changed: ${keys.length > 0 ? keys.join(', ') : '(nothing)'}`;
1227
+ }
1228
+ /** One transition = one fresh StageContext = one CommitBundle. */
1229
+ #commitDelta(stageName, runtimeStageId, readKeys, delta) {
1230
+ const ctx = new StageContext('', stageName, stageName, this.#heap, '', this.#log);
1231
+ ctx.runtimeStageId = runtimeStageId;
1232
+ ctx.useCommitValues(this.#commitValues);
1233
+ ctx.useWriteProvenance('reads-prefix');
1234
+ const scope = new ScopeFacade(ctx, stageName);
1235
+ scope.attachScopeRecorder(this.#recorder);
1236
+ for (const key of readKeys)
1237
+ scope.getValue(key);
1238
+ for (const [key, value] of Object.entries(delta)) {
1239
+ scope.setValue(key, value, this.#redacted.has(key));
1240
+ }
1241
+ ctx.commit();
1242
+ }
1243
+ }
1244
+ /**
1245
+ * Bounded, firewall-safe copy of a handler's return value for the DATA channel.
1246
+ * Caps depth/breadth/string length (search results can be large), drops
1247
+ * functions, and tolerates cycles via the depth cap — so a handler return can
1248
+ * never blow up a tool result or smuggle live references into the record.
1249
+ */
1250
+ function sanitizeProduced(value, depth = 0) {
1251
+ if (typeof value === 'function')
1252
+ return undefined;
1253
+ if (typeof value === 'string')
1254
+ return value.length > 200 ? `${value.slice(0, 200)}…` : value;
1255
+ if (value === null || typeof value !== 'object')
1256
+ return value; // number, boolean, undefined
1257
+ if (depth >= 4)
1258
+ return null; // deep enough — and a cycle backstop
1259
+ if (Array.isArray(value)) {
1260
+ return value.slice(0, 30).map((item) => sanitizeProduced(item, depth + 1));
1261
+ }
1262
+ const out = {};
1263
+ let count = 0;
1264
+ for (const [key, child] of Object.entries(value)) {
1265
+ if (count++ >= 40)
1266
+ break;
1267
+ const clean = sanitizeProduced(child, depth + 1);
1268
+ if (clean !== undefined)
1269
+ out[key] = clean;
1270
+ }
1271
+ return out;
1272
+ }
1273
+ /** Detach a value defensively — structuredClone, or the ref if it can't be cloned. */
1274
+ function cloneSafe(value) {
1275
+ try {
1276
+ return structuredClone(value);
1277
+ }
1278
+ catch {
1279
+ return value; // non-cloneable payload (function/DOM node): best-effort, keep the ref
1280
+ }
1281
+ }
1282
+ function validatePayload(schema, payload) {
1283
+ const validator = schema;
1284
+ if (typeof validator.safeParse === 'function') {
1285
+ try {
1286
+ const result = validator.safeParse(payload);
1287
+ return result.success ? { ok: true } : { ok: false, issues: String(result.error) };
1288
+ }
1289
+ catch (error) {
1290
+ return { ok: false, issues: String(error) };
1291
+ }
1292
+ }
1293
+ if (typeof validator.parse === 'function') {
1294
+ try {
1295
+ validator.parse(payload);
1296
+ return { ok: true };
1297
+ }
1298
+ catch (error) {
1299
+ return { ok: false, issues: String(error) };
1300
+ }
1301
+ }
1302
+ // Plain JSON Schema: describes the payload to the LLM; v0 ships no JSON-Schema validator.
1303
+ return { ok: true };
1304
+ }
1305
+ //# sourceMappingURL=session.js.map