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
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanjay Krishna Anbalagan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,524 @@
1
+ <h1 align="center">HACI&nbsp;Footprint</h1>
2
+
3
+ <p align="center"><b>Human &amp; Agent · Computer Interaction</b></p>
4
+
5
+ <p align="center">
6
+ <strong>Your agent can reach your app — but it's flying blind.<br/>
7
+ Your app already knows what can be done here, and by whom. Hand the agent that map.</strong>
8
+ </p>
9
+
10
+ <p align="center">
11
+ Turn a web app's interaction surface into a typed, traversable <strong>skill graph</strong> an LLM agent
12
+ can plan over and act on — on behalf of the signed-in user, through the app's own buttons and handlers.
13
+ </p>
14
+
15
+ <p align="center">
16
+ <picture>
17
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/haci-hero-dark.svg">
18
+ <source media="(prefers-color-scheme: light)" srcset="docs/assets/haci-hero-light.svg">
19
+ <img alt="HACI Footprint — the classic term HCI becomes HACI as a yellow A (Agent) joins the Human's side; a person and an AI act on the app as a team." src="docs/assets/haci-hero-light.svg" width="100%">
20
+ </picture>
21
+ </p>
22
+
23
+ <p align="center">
24
+ <img src="https://img.shields.io/badge/status-beta%20·%20pre--1.0-e0a400?style=flat" alt="beta, pre-1.0">
25
+ <img src="https://img.shields.io/badge/tests-273%20passing-f5b301?style=flat" alt="273 tests passing">
26
+ <img src="https://img.shields.io/badge/TypeScript-strict-f5b301?style=flat" alt="TypeScript strict">
27
+ <img src="https://img.shields.io/badge/core-zero--dependency-f5b301?style=flat" alt="zero-dependency core">
28
+ <img src="https://img.shields.io/badge/serves-a%20real%20MCP%20server-f5b301?style=flat" alt="serves a real MCP server">
29
+ <a href="https://github.com/footprintjs/hcifootprint/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a>
30
+ </p>
31
+
32
+ <p align="center">
33
+ <strong>🌐 <a href="https://footprintjs.github.io/hcifootprint/">footprintjs.github.io/hcifootprint</a></strong> — the story in three lenses: <a href="https://footprintjs.github.io/hcifootprint/">Read</a> · <a href="https://footprintjs.github.io/hcifootprint/?view=scrolly">Scroll</a> · <a href="https://footprintjs.github.io/hcifootprint/?view=slides">Watch</a>
34
+ </p>
35
+
36
+ ```bash
37
+ npm install hcifootprint
38
+ ```
39
+
40
+ > **Beta · pre-1.0** — the API can still change until `1.0`. The npm publish lands with `1.0`; until then, install from this repo or pin a commit.
41
+
42
+ > **Agents:** read [`llms.txt`](llms.txt) — the entire API surface on one self-contained page.
43
+
44
+ ---
45
+
46
+ ## Agents driving UIs are flying blind
47
+
48
+ An agent can already reach your app. The problem is *how* it operates one:
49
+
50
+ | How agents drive a UI today | The cost |
51
+ |---|---|
52
+ | Screenshot the page, reason over pixels | slow and fragile — redone every single turn |
53
+ | Dump the DOM into the prompt | ~100k tokens, and it still guesses at what does what |
54
+ | Hard-coded selectors / RPA scripts | break on the next redesign |
55
+
56
+ All three relearn your app from scratch on every visit. But a returning human already carries a mental
57
+ model — where things are, what leads where, what they're allowed to do. Your app holds that same map.
58
+ **HACI Footprint hands it to the agent** as a typed skill graph the agent *traverses*, instead of a DOM it
59
+ re-reads — with a *you-are-here* pin so it only ever sees what is actually doable right now.
60
+
61
+ ## What it is
62
+
63
+ For decades we've designed the interaction between a **human** and a **computer** — that's HCI. Now the
64
+ human isn't alone: an **agent** joins their side, acting for them. Human **and** agent, working the computer
65
+ as a team — that's **HACI**, and this is the layer for it.
66
+
67
+ The key idea, and the reason it's safe to adopt: **you are not opening your backend to an agent — you are
68
+ letting it drive the frontend a human already can.**
69
+
70
+ - **Auth and permissions are unchanged.** The agent acts *as the signed-in user*, through your app's own buttons and handlers. It inherits exactly the capability envelope the user already has. No new endpoints, no new grants, **no new attack surface.**
71
+ - **The app is already the boundary.** Your UI decides what can be done. The agent can't do anything a user couldn't.
72
+ - **Both drive the same live session.** The human's clicks and the agent's actions flow through one session — a team on one side of the screen. That's the "Human **and** Agent" in the name.
73
+
74
+ ## Quick start — author → connect → serve
75
+
76
+ Three steps, and the first two run offline with no API key.
77
+
78
+ **1. Describe the app** as the tree you already picture — pages, the containers inside them, and the actions
79
+ inside those. Each action needs one sentence; that sentence is both your label and the tool description the
80
+ LLM reads.
81
+
82
+ ```ts
83
+ import { buildNavigationGraph } from 'hcifootprint';
84
+
85
+ const graph = buildNavigationGraph('shop', {
86
+ pages: {
87
+ catalog: {
88
+ tools: {
89
+ 'search': { does: 'Search dresses by name or color' },
90
+ 'add-to-cart': { does: 'Add the open dress to the cart', when: { authenticated: { eq: true } } },
91
+ },
92
+ },
93
+ checkout: {
94
+ modals: {
95
+ 'confirm-order': { tools: { 'place-order': { does: 'Place the order', confirm: true } } },
96
+ },
97
+ },
98
+ },
99
+ skills: {
100
+ purchase: { does: 'Buy a dress end to end', steps: ['add-to-cart', 'place-order'] },
101
+ },
102
+ });
103
+ ```
104
+
105
+ D18 `buildNavigationGraph` is the canonical authoring surface; the v1 `skillGraph()` fluent builder
106
+ remains as legacy sugar.
107
+
108
+ **2. Connect it** to your running app through three ordinary wires. Components register what they have *when
109
+ they render*: registration hands back a handle (you never invent a group name), your existing functions bind
110
+ by reference, the router owns the page.
111
+
112
+ ```ts
113
+ const session = graph.createSession();
114
+
115
+ // when the component that renders the catalog mounts:
116
+ const group = session.registerToolGroup('catalog', {
117
+ handlers: { 'search': (input) => shop.search(input.query) }, // your own function, by reference
118
+ });
119
+ group.setEnabled('search', false); // grey a button out; group.unregister() on unmount
120
+
121
+ // your existing wires report reality:
122
+ session.updateState({ authenticated: true }); // store tap → guards re-evaluate
123
+ session.sync('checkout'); // router change → the cursor moves
124
+
125
+ // passive observers — never business logic:
126
+ session.on('structure', () => rerenderToolPanel());
127
+ session.on('gap', (row) => telemetry.send(row));
128
+ ```
129
+
130
+ Node paths are **typed**: `registerToolGroup('catalog.filtr-rail')` is a compile error, not a silent no-op.
131
+
132
+ **3. Serve it to the LLM** as a fixed set of MCP-shaped tools. The tool list never changes; what's doable
133
+ *right now* arrives inside each tool result.
134
+
135
+ ```ts
136
+ import { skillsAsTools } from 'hcifootprint';
137
+
138
+ const port = skillsAsTools(session);
139
+ port.tools(); // static tool array — one per skill + whats_here / do_action / why
140
+ port.call('shop.skill.purchase', {}); // → { readySteps, judgment, youAreOn, ... }
141
+ ```
142
+
143
+ The agent plans over skills, sees only what's available at the current position, and acts through your own
144
+ handlers — with the human able to approve high-effect steps.
145
+
146
+ ## The adoption ladder — start in guide mode
147
+
148
+ You don't wire everything at once. Adoption is a ladder, and the first rung needs no handlers at all.
149
+
150
+ **Phase 0 — guide mode (read-only).** Wire only the two reporting calls: `sync()` when the router moves and
151
+ `updateState()` when your store changes. Register nothing. The agent can now read the position and *plan* over
152
+ the declared action space (`whats_here` / `available()`), but it acts on nothing — with no handlers bound,
153
+ every offered edge is plannable-only. This rung is **zero-risk**: it cannot touch your app. The one rule is
154
+ **never `fire()` an unregistered tool.** Firing records a transition your app never performed, which desyncs
155
+ the cursor and mis-attributes the next real state report. In guide mode, move the cursor with `sync()` and
156
+ `updateState()` only.
157
+
158
+ **Phase 1 — register handlers.** As components mount, `registerToolGroup(path, { handlers })` binds your
159
+ existing functions by reference, so firing runs the app's own code. Edges now report `materialized` (false =
160
+ still declared-only, true = wired), and you watch the surface light up.
161
+
162
+ **Phase 2 — serve an agent (Mode B).** `skillsAsTools(session)` hands your host a fixed MCP tool set; the
163
+ agent plans over skills and acts through your handlers. One authored graph carried you the whole way — no
164
+ rung forced you to maintain a second, stripped-down copy.
165
+
166
+ ---
167
+
168
+ ## Pick your door
169
+
170
+ | 🔌 Serving an agent? | 🕳️ Deciding what to build? | ✅ Shipping to production? |
171
+ |---|---|---|
172
+ | A fixed, cache-warm tool surface any MCP host can drive — Claude Desktop, LangGraph, a raw loop. | Every ask your app *can't* serve becomes a token-lean gap row — a demand-driven backlog. | A drift harness that fails CI when the graph and the app stop agreeing. |
173
+ | [→ Serve to any agent ↓](#-serve-it-to-any-agent--the-mcp-surface) | [→ The gap loop ↓](#-the-gap-loop--grow-the-app-around-real-demand) | [→ Keep the graph true ↓](#-keep-the-graph-true--the-drift-harness) |
174
+
175
+ ---
176
+
177
+ ## 🔌 Serve it to any agent — the MCP surface
178
+
179
+ The tool array the LLM sees holds **one tool per skill** plus three fixed generics (`whats_here`, `do_action`, `why`),
180
+ and it **never changes for the life of a conversation**. Disclosure rides the *result* channel: every call
181
+ returns `readySteps` — what's fireable at the current cursor — and the model acts by calling the same skill
182
+ tool again with a `step`. Tools render first in the prompt, so a stable tool set keeps the **prompt cache
183
+ warm**, and any plain MCP host can drive it with no dynamic-tool support required.
184
+
185
+ ### One turn, end to end
186
+
187
+ A single request, *"find a red dress under $150 and buy it,"* flows through five parties. The navigation
188
+ graph is what makes each step position-aware — the session only ever offers what is fireable at the current
189
+ cursor.
190
+
191
+ ```mermaid
192
+ sequenceDiagram
193
+ actor U as User
194
+ participant A as Agent (LLM)
195
+ participant T as skillsAsTools port<br/>(fixed MCP tools)
196
+ participant S as InteractionSession<br/>+ navigation graph
197
+ participant App as Your app<br/>handlers · store · router
198
+
199
+ U->>A: find a red dress under $150 and buy it
200
+ Note over A,T: The tool set is FIXED for the whole conversation — the cache stays warm
201
+
202
+ A->>T: shop.skill.find-dress (no step yet — open it)
203
+ T->>S: commitSkill find-dress
204
+ S-->>T: readySteps = search-dresses (disclosure rides the RESULT)
205
+ T-->>A: readySteps + youAreOn
206
+
207
+ A->>T: shop.skill.find-dress step=search-dresses input=red
208
+ T->>S: fire catalog.search-dresses
209
+ S->>App: run your handler shop.search — as the signed-in user
210
+ App-->>S: store tap updateState + the matched dresses
211
+ S-->>T: did + readySteps=view-dress + data=dresses
212
+ Note right of S: results ride the DATA channel — untrusted text, never instructions
213
+ T-->>A: the dresses to pick from + the next ready step
214
+
215
+ A->>T: shop.skill.purchase step=place-order
216
+ T->>S: fire checkout.place-order (high-effect)
217
+ S-->>A: judgment = needs-confirm — never auto-crossed
218
+ A->>U: Approve placing the order?
219
+ U-->>A: approve
220
+ A->>T: shop.skill.purchase step=place-order confirm=true
221
+ T->>S: fire checkout.place-order
222
+ S->>App: shop.placeOrder
223
+ App-->>A: Ordered the Floral Wrap Dress — $120
224
+ ```
225
+
226
+ The load-bearing moves, in order: the **tool set never changes**, so the prompt cache stays warm; the session
227
+ answers *"what can I do here?"* from the **graph at the current cursor**, not a DOM dump; firing runs **your
228
+ own handler** as the signed-in user; produced data comes back on the **data channel** (so untrusted content
229
+ can't become instructions); and a **high-effect** step stops for **human approval** before it ever fires.
230
+
231
+ ### What each turn actually sends to the model
232
+
233
+ Every turn is **one** Messages API request with three channels:
234
+
235
+ ```text
236
+ POST /v1/messages
237
+ ├── system "You are the shopping assistant… here is how to work…" ← authored by YOU, only
238
+ ├── tools[] shop.skill.find-dress · shop.skill.purchase · …
239
+ │ shop.whats_here · shop.do_action · shop.why ← FIXED — identical every turn
240
+ └── messages … prior turns …
241
+ tool_result { readySteps: […], data: [dress names…] } ← the app's DATA lands here
242
+ user: "find a red dress under $150 and buy it" ← the user's own text, as-is
243
+ ```
244
+
245
+ Two things worth making explicit:
246
+
247
+ - **The user's message is passed as-is.** It's the operator talking to *their own* assistant, so it belongs in the instruction channel — nothing is stripped or paraphrased.
248
+ - **App content never enters the instruction channel.** Product names and search results ride **only** inside `tool_result` data — never the `system` prompt, never a tool's `description`. So a dress literally named `IGNORE PREVIOUS INSTRUCTIONS…` reads as harmless data. That's the two-string-class firewall.
249
+
250
+ The model never receives a growing list of tools. It picks from the fixed array and passes a **step name** —
251
+ read from `readySteps` in the previous result — as an *argument*. The tool *set* is constant; only the `step`
252
+ changes. That's exactly what keeps the cache warm and lets any host serve it.
253
+
254
+ **After a write-step, re-read `whats_here`.** A step that changes state settles *asynchronously* — `fire()`
255
+ returns `settlement: 'awaiting-state'` and the step stays pending until your app reports the new state back
256
+ through `updateState()`. Until it does, the step that just fired is held out of `readySteps` (so the model
257
+ can't double-fire it), and any step that depends on its write isn't ready yet. So an agent re-reads
258
+ `whats_here` (or re-opens the skill) after a write-step, rather than trusting the `readySteps` from the
259
+ write's own result.
260
+
261
+ ### Plug into any framework — or run a real MCP server
262
+
263
+ hcifootprint is **not tied to any agent framework**. The library hands your host two functions:
264
+
265
+ ```ts
266
+ const port = skillsAsTools(session);
267
+
268
+ const tools = port.tools(); // fixed, MCP-shaped: { name, description, inputSchema }[]
269
+ // → register `tools` with your agent (LangGraph, LangChain, an Anthropic/OpenAI loop, …)
270
+
271
+ // then, inside each tool's executor, route the model's tool_use to:
272
+ const result = port.call(toolName, toolInput); // → return `result` as the tool_result
273
+ ```
274
+
275
+ That's the whole integration — **`tools()` + `call()`**. To expose it as a **real MCP server** any client
276
+ auto-discovers, there's a one-liner:
277
+
278
+ ```ts
279
+ import { mcpServer } from 'hcifootprint/mcp';
280
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
281
+
282
+ const server = mcpServer(session); // tools/list + tools/call, wired to the session
283
+ await server.connect(new StdioServerTransport()); // or an SSE / streamable-HTTP transport
284
+ ```
285
+
286
+ `mcpServer` returns a standard `@modelcontextprotocol/sdk` `Server`, so **you pick the transport** and run it
287
+ wherever your session lives. Over MCP, a high-effect step returns `judgment: 'needs-confirm'` and the host
288
+ decides how to get approval before calling again with `confirm: true` — a portable human-in-the-loop that
289
+ needs no framework-specific pause/resume. The SDK is an **optional peer dependency**, imported only behind the
290
+ `hcifootprint/mcp` subpath, so the core stays zero-dependency and you pull it in only if you use it.
291
+
292
+ > This cooperates with Anthropic's MCP rather than competing with it: the skill graph *is* the server, and
293
+ > any MCP host drives the same live session.
294
+
295
+ ---
296
+
297
+ ## 🕳️ The gap loop — grow the app around real demand
298
+
299
+ You don't have to build the whole agentic experience up front. Ship a thin skill graph, then let real demand
300
+ tell you what to build next.
301
+
302
+ Your UI is the boundary of what an agent *can* do. When a user asks for something the UI **can't** serve,
303
+ HACI Footprint doesn't just fail — it records a **gap**: a token-lean, structured row (the ask, the position,
304
+ what *was* available) wired to your telemetry. The agent calls the gap tool **before** it apologizes, so
305
+ every miss becomes a ledger row your team reads. Cluster those rows and you have a **demand-driven backlog**
306
+ for your agentic app.
307
+
308
+ A concrete one: two customers check out at the same moment, and a race lets one order through while the other
309
+ fails. The loser asks the assistant *"why did mine fail?"* — but **that reason is in your backend logs, not
310
+ the UI.** The agent can't answer, so it files a gap with reason `needs-backend-data`. Now you know precisely
311
+ what to build for the next release — a small UI report, or a backend tool the agent can call. **You grow the
312
+ app by locating the missing data, not by guessing at features.**
313
+
314
+ ```ts
315
+ session.gaps(); // export the whole ledger to your analytics / triage
316
+ session.onGap((row) => { … }); // or stream rows live (sugar for session.on('gap', …))
317
+ ```
318
+
319
+ Rows are deliberately structured and name-only — the ask plus lists of available action/skill names, never
320
+ descriptions or transcripts — so a batch triage LLM can cluster thousands of them cheaply.
321
+
322
+ ---
323
+
324
+ ## 🛟 Human-in-the-loop
325
+
326
+ High-effect actions (`confirm: true` in the graph) stop at a `needs-confirm` gate and are **never
327
+ auto-crossed**. The agent must ask the human, then call again with `confirm: true`. Two ways to run the
328
+ approval:
329
+
330
+ - **Over MCP** — the gate is just data in the result (`judgment: 'needs-confirm'`), and the host decides how to collect the yes. Portable, framework-free.
331
+ - **In-process** — pause the agent's ReAct loop on a checkpoint, hand control to the human, and resume exactly where it stopped. The demo implements this with agentfootprint's pause/resume checkpointing.
332
+
333
+ Either way, the gate is enforced at the session, so an agent can't fire a high-effect action without a real
334
+ approval.
335
+
336
+ ## 🔒 Honest by construction
337
+
338
+ Two properties do most of the safety work:
339
+
340
+ - **It says what it can't see.** Anything the runtime *derives* rather than *observes* is flagged — `activation: 'assumed'`, `presence: 'unknown'`, `guardUnevaluated`. Every refused action returns a *typed* reason (`BLOCKED_BY_OVERLAY`, `STILL_MOUNTING`, `GUARD_FAILED`, …) and is logged, so the agent replans instead of hallucinating.
341
+ - **Untrusted content can never become instructions.** Page text, product names, and user content ride a strict *data* channel; only your authored strings reach the planner's *instruction* channel. It's a firewall against prompt injection — proven in the demo by a product literally named `IGNORE PREVIOUS INSTRUCTIONS…`, which renders as harmless data everywhere.
342
+
343
+ Under the hood, every action lands in a real [footprintjs](https://github.com/footprintjs/footPrint) commit
344
+ log, so `session.why(key)` gives a causal answer to *"why is the app in this state?"* with zero extra code.
345
+
346
+ ## Guard semantics (read this before writing a projector)
347
+
348
+ A guard (`when:`) is a **flat** filter over your projected state — `key: { op: value }`, ANDed across keys,
349
+ operators `eq / ne / gt / gte / lt / lte / in / notIn`. There is no `$or` and no nesting; the filter is
350
+ deliberately flat. To express "A **or** B", derive a boolean in your projector and guard on that:
351
+
352
+ ```ts
353
+ session.updateState({ canCheckout: cart.length > 0 || savedOrder != null });
354
+ // then author: when: { canCheckout: { eq: true } }
355
+ ```
356
+
357
+ A guard key the projected state has **never contained** is not treated as false. The edge is served anyway,
358
+ carrying a `guardUnevaluated` marker — the condition is flagged as taken on faith, with the app still the
359
+ enforcer. (A key that *is* present but fails its test hides the edge, as you'd expect.) The reason: absence of
360
+ data must not masquerade as `false`. That honesty is what lets one authored graph work before every key is
361
+ wired — but it also means an under-seeded projector quietly turns real decisions into flagged guesses.
362
+
363
+ So seed every guard key up front. `graph.requiredStateKeys()` returns exactly that set — every key your
364
+ guards and skill preconditions read, sorted and deduped — as the checklist for your state projector.
365
+
366
+ ---
367
+
368
+ ## ✅ Keep the graph true — the drift harness
369
+
370
+ The navigation graph is a **second artifact** you keep alongside the real app, so it **drifts** as the app
371
+ changes: a button's disable rule moves, a page is removed, a handler starts writing different state.
372
+ `hcifootprint/testing` catches that drift **in dev and CI, before production**. It adds **zero dependencies**,
373
+ is tree-shakeable, and drives the **real session** (never a copy), in two layers.
374
+
375
+ **1. `lintGraph` / `checkGraph` — static, no test code.** Read the graph alone and report stale logic: a
376
+ control gated on state nothing produces, a guard that can never be true, a skill that can never finish, a page
377
+ nothing can reach. This is the cheap CI gate. It's **advisory by default** and only escalates to hard errors
378
+ once you tell it the starting state — it never cries "dead" over a key it can't see.
379
+
380
+ ```ts
381
+ import { lintGraph, checkGraph, expectNoStaleLogic } from 'hcifootprint/testing';
382
+
383
+ lintGraph(graph); // → findings you can inspect
384
+ expectNoStaleLogic(graph, { initialState }); // → throws in CI if the graph drifted
385
+
386
+ // Or the one-call health verdict — findings grouped by drift type + a printable report:
387
+ const health = checkGraph(graph, { initialState });
388
+ if (!health.ok) { console.error(health.summary); process.exit(1); }
389
+ ```
390
+
391
+ `checkGraph` is static and pure — import it from `hcifootprint/testing/lint` for a CI step that loads no
392
+ engine code at all. Every finding names **what** drifted and **where**, and states the two remedies (update
393
+ the graph, or fix the app that changed by mistake). It surfaces the drift; **the fix is your team's call.**
394
+
395
+ **2. `testApp` — "Playwright for your interaction logic, minus the browser."** Write mock handlers (one per
396
+ action, returning a state change), then drive the graph as a **user** (clicking) or as the **agent** (the
397
+ real Mode B tool path). The library's own honesty marker, `effectVerified`, flips false when a handler no
398
+ longer does what the graph declares — that *is* the behavioral-drift alarm. **Report by default; pass
399
+ `strict: true` to fail the instant drift appears.**
400
+
401
+ ```ts
402
+ import { testApp } from 'hcifootprint/testing';
403
+
404
+ const app = testApp(graph, {
405
+ initialState: { cartCount: 0 },
406
+ resolvers: { 'add-to-cart': (_p, { state }) => ({ patch: { cartCount: state.cartCount + 1 } }) },
407
+ });
408
+
409
+ await app.user.fire('add-to-cart'); // drive like a human
410
+ app.expectState({ cartCount: 1 });
411
+ await app.agent.skill('purchase', { step: 'go-to-cart' }); // drive like the LLM
412
+ app.expectOn('cart');
413
+ app.report(); // { ok, effectDrift, unevaluatedGuards, gaps }
414
+ ```
415
+
416
+ **Honest boundary (say it out loud).** This tests interaction **logic** above the binding — guards, skills,
417
+ navigation, effect claims, typed rejections. It does **not** verify pixels, the DOM, or that a binding
418
+ resolves to a real element — that stays **Playwright's** job, and this complements it rather than replacing
419
+ it. A green `lintGraph` proves the graph is internally consistent, **not** that the app works: it reasons
420
+ about which state *keys* move, never their values, so a right-key/wrong-value bug is the harness's job
421
+ (`effectVerified`), not the linter's. And a mock is a *simulation* — if it diverges from the real handler, the
422
+ test is green while prod is broken. For full fidelity, pass `testApp({ session })` with your own wired
423
+ session.
424
+
425
+ ---
426
+
427
+ ## 💰 Bounded token cost
428
+
429
+ Naively, "give the LLM your app" means dumping every action into the prompt — and the bill grows with your
430
+ codebase. HACI Footprint doesn't do that:
431
+
432
+ - The skill graph **loads only what the current position needs**, not the whole app.
433
+ - The tool list stays **fixed** (one tool per skill); what's fireable right now travels in tool *results*, not by rewriting the tool array. So the **prompt cache stays warm** across the whole conversation.
434
+
435
+ Token cost tracks the task at hand, not the size of your codebase.
436
+
437
+ ## Frontend: framework-agnostic
438
+
439
+ The core is plain TypeScript and knows nothing about your framework — you connect it through three ordinary
440
+ wires (a store subscription, router events, your existing handlers). The
441
+ [dress-shop demo](https://github.com/footprintjs/hcifootprint-demo) shows the whole wiring on a real
442
+ storefront with an AI stylist.
443
+
444
+ **On the roadmap:** a React adapter (`useMount`) and a DOM actuation adapter (act through the real DOM), plus
445
+ a demo gallery — the same app wired in Angular, React, Vue, and an iframe, shown as pills on one index page.
446
+ One graph, many frontends.
447
+
448
+ ## The model — Affordance &amp; Transition
449
+
450
+ For the curious, the whole thing rests on two atoms:
451
+
452
+ ```
453
+ Affordance = binding × guard × effect × schema (the static capability)
454
+ Transition = cause × payload × outcome (each occurrence)
455
+ ```
456
+
457
+ - **`binding`** — how to reach the surface (ARIA role + name first).
458
+ - **`guard`** — a serializable filter over projected state that decides what's *offered*.
459
+ - **`effect`** — a *claim* about your handler, verified at settlement (`effectVerified`).
460
+ - **`cause`** — who did it: `user`, `agent`, or `system`. First-class provenance for accountability between cooperating agents — **not** a security boundary; enforce that server-side.
461
+
462
+ Deeper topics — on-demand disclosure (skill frames), the context brief, the navigation-graph semantics
463
+ (modals / tabs / repeats), and the act→data-back channel (`session.producedFor`) — are documented inline in
464
+ the source (`src/*/README.md`) and in [`docs/design/`](docs/design/).
465
+
466
+ ---
467
+
468
+ ## Live demo — the dress shop, in three commits
469
+
470
+ The [dress-shop demo](https://github.com/footprintjs/hcifootprint-demo) tells the pitch as a diff, because
471
+ the diff *is* the demo:
472
+
473
+ <p align="center">
474
+ <a href="https://youtu.be/vx5amF94ipI">
475
+ <img src="https://img.youtube.com/vi/vx5amF94ipI/maxresdefault.jpg" alt="Watch: turn your web app into an agentic app with HACI Footprint (demo video)" width="720">
476
+ </a>
477
+ <br>
478
+ <em>▶ Watch the demo — a vanilla dress shop becomes an agent-operable app (37 sec)</em>
479
+ </p>
480
+
481
+ | Commit | What it adds | What it proves |
482
+ |---|---|---|
483
+ | 1 | The plain store — subscribers, a tiny router, handler methods. | The unbiased baseline, built with zero knowledge of any agent layer. |
484
+ | 2 | The agent layer (this library): the declared graph + three wires. | `git diff` of the app's own code → **empty**. Position-aware tools, guards mirroring the app's invariants, hostile catalog text confined to the data channel. |
485
+ | 3 | The assistant (agentfootprint + Claude) driving the same session. | The whole family in one loop, with **human-in-the-loop by checkpoint** on order placement. |
486
+
487
+ ```bash
488
+ git clone https://github.com/footprintjs/hcifootprint-demo && cd hcifootprint-demo/dress-shop
489
+ npm install
490
+ npm test # commits 1+2: the app's tests + the integration proof (no API key)
491
+
492
+ cp .env.template .env # add your ANTHROPIC_API_KEY
493
+ npm run chat # commit 3: the assistant in your terminal
494
+ npm run serve # the storefront in a browser — click OR chat, same session
495
+ npm run drift # watch the drift harness catch a deliberately-drifted graph
496
+ ```
497
+
498
+ `npm run serve` also ships an **agent debugger** at `/debug` — powered by
499
+ [AgentThinkingUI](https://github.com/footprintjs/agentThinkingUI), it drives the *same* live session as the
500
+ storefront, so you chat and watch the agent reason beat-by-beat (`whats_here` → open a skill → fire steps →
501
+ the answer).
502
+
503
+ ## Development
504
+
505
+ ```bash
506
+ npm install
507
+ npm test # vitest — 273 tests
508
+ npm run typecheck # src + tests
509
+ npm run build
510
+ ```
511
+
512
+ Depends on `footprintjs` ≥ 9.10 (`evaluateFilter` / `normalizeSchema` exports). Sibling of **footprintjs**
513
+ (backend logic) and **agentfootprint** (agents) — one self-explaining trace substrate underneath.
514
+
515
+ ## Built on
516
+
517
+ [footprintjs](https://github.com/footprintjs/footPrint) — the flowchart pattern for backend code. Every
518
+ action a session fires lands in a footprintjs commit log, which is what makes `session.why(key)` a real
519
+ backward causal slice rather than a guess. You don't need to learn footprintjs to use this library, but if you
520
+ want to build primitives at that depth, [start there](https://footprintjs.github.io/footPrint/).
521
+
522
+ ## License
523
+
524
+ MIT © [Sanjay Krishna Anbalagan](https://github.com/sanjay1909)