thumbgate 1.35.0 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +336 -3
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +581 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +9 -3
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +6 -0
  34. package/config/model-candidates.json +312 -29
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +390 -148
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/billing.js +1 -1
  89. package/scripts/broker-execution-receipts.js +719 -0
  90. package/scripts/budget-aware-gates-proof.js +423 -0
  91. package/scripts/claude-feedback-sync.js +29 -3
  92. package/scripts/claw-harness-production.js +237 -0
  93. package/scripts/cli-schema.js +163 -1
  94. package/scripts/codex-runbook-flywheel.js +318 -0
  95. package/scripts/context-footprint.js +186 -0
  96. package/scripts/contextfs.js +143 -61
  97. package/scripts/dashboard.js +251 -32
  98. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  99. package/scripts/docker-sandbox-planner.js +18 -0
  100. package/scripts/double-blind-eval-protocol.js +252 -0
  101. package/scripts/edotenv-rl-gateway.js +259 -0
  102. package/scripts/ensure-production-search-corpus.js +162 -0
  103. package/scripts/eval-holdout.js +311 -0
  104. package/scripts/feedback-aggregate.js +21 -2
  105. package/scripts/feedback-loop.js +87 -5
  106. package/scripts/feedback-quality.js +9 -0
  107. package/scripts/file-ledger-lock.js +4 -1
  108. package/scripts/financial-control-plane.js +41 -1
  109. package/scripts/find-dormant-requires.js +118 -0
  110. package/scripts/fs-utils.js +84 -8
  111. package/scripts/gates-engine.js +810 -63
  112. package/scripts/generate-case-study-outreach.js +24 -15
  113. package/scripts/git-at-scale.js +628 -0
  114. package/scripts/governance-conflict-audit.js +1650 -0
  115. package/scripts/governance-difficulty-curriculum.js +328 -0
  116. package/scripts/graphrag-retrieval.js +275 -0
  117. package/scripts/gurobi-optimizer.js +324 -0
  118. package/scripts/gurobi_optimizer.py +485 -0
  119. package/scripts/harness-selector.js +82 -1
  120. package/scripts/hidden-entry-points.js +284 -0
  121. package/scripts/human-escalation.js +199 -1
  122. package/scripts/hybrid-feedback-context.js +152 -19
  123. package/scripts/intent-governed-execution.js +602 -0
  124. package/scripts/intervention-policy.js +123 -20
  125. package/scripts/jit-harness-compose.js +628 -0
  126. package/scripts/jsonl-watcher.js +10 -0
  127. package/scripts/lesson-embedding-index.js +95 -12
  128. package/scripts/lesson-retrieval.js +105 -19
  129. package/scripts/local-model-profile.js +19 -2
  130. package/scripts/mailer/resend-mailer.js +1 -1
  131. package/scripts/matryoshka-embedding.js +235 -0
  132. package/scripts/mcp-oauth.js +42 -4
  133. package/scripts/mcp-session-handles.js +1016 -0
  134. package/scripts/mcp-wiring-doctor.js +314 -0
  135. package/scripts/memory-firewall.js +115 -2
  136. package/scripts/memory-scope-readiness.js +299 -0
  137. package/scripts/memory-vs-rag-route.js +161 -0
  138. package/scripts/model-tier-router.js +148 -21
  139. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  140. package/scripts/openui-catalog-compose-honesty.js +593 -0
  141. package/scripts/operational-integrity.js +19 -1
  142. package/scripts/override-audit.js +213 -0
  143. package/scripts/package-manager-honesty-doctor.js +458 -0
  144. package/scripts/pr-manager.js +63 -1
  145. package/scripts/prove-herdr-adapter.js +52 -0
  146. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  147. package/scripts/prove-workos.js +73 -0
  148. package/scripts/provider-attestation-conformance.js +192 -0
  149. package/scripts/provider-receipt-contract.js +136 -0
  150. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  151. package/scripts/radware-threat-defense.js +280 -0
  152. package/scripts/rag-embedding-identity.js +221 -0
  153. package/scripts/rag-precision-guardrails.js +112 -2
  154. package/scripts/remote-feedback-capture.js +159 -0
  155. package/scripts/research-agent-harness.js +256 -0
  156. package/scripts/rsi-safety-hillclimb.js +200 -0
  157. package/scripts/rule-sprawl.js +188 -0
  158. package/scripts/schedule-manager.js +147 -0
  159. package/scripts/self-heal.js +8 -0
  160. package/scripts/session-lease.js +415 -0
  161. package/scripts/simatree-data-governance.js +347 -0
  162. package/scripts/slo-alert-engine.js +172 -7
  163. package/scripts/solver-parity.js +539 -0
  164. package/scripts/stealth-memory-injection-gate.js +333 -0
  165. package/scripts/switchyard-router.js +366 -0
  166. package/scripts/telemetry-analytics.js +84 -27
  167. package/scripts/temporal-decay-weighting.js +138 -0
  168. package/scripts/test-all.js +165 -0
  169. package/scripts/token-savings.js +42 -0
  170. package/scripts/tool-kpi-tracker.js +108 -5
  171. package/scripts/tool-registry.js +193 -5
  172. package/scripts/universal-claim-evaluator.js +14 -2
  173. package/scripts/vector-store.js +279 -9
  174. package/scripts/workflow-notebook.js +391 -0
  175. package/scripts/workflow-sentinel.js +111 -12
  176. package/scripts/workos-production-guard.js +260 -0
  177. package/scripts/workspace-search-route.js +515 -0
  178. package/server.json +2 -2
  179. package/src/agent-identity-boundary.js +76 -0
  180. package/src/agent-retrieval-cache.js +155 -0
  181. package/src/alert-noise-ledger.js +502 -0
  182. package/src/api/server.js +724 -153
  183. package/src/git-fast-cache.js +220 -0
  184. package/src/git-wal-sync.js +156 -0
  185. package/src/hash-anchored-edit.js +82 -0
  186. package/src/hermes-platform-protocol.js +475 -0
  187. package/src/hermes-sync-plane.js +241 -0
  188. package/src/index.js +30 -1
  189. package/src/iso42001-compliance-guard.js +97 -0
  190. package/src/latency-budget.js +244 -0
  191. package/src/mcp-writeguard.js +316 -0
  192. package/src/miminions-adapter.js +106 -0
  193. package/src/pipeline-compass.js +104 -0
  194. package/src/ppl-alert-pipeline.js +284 -0
  195. package/src/rendezvous-router.js +90 -0
  196. package/src/security-questionnaire.js +195 -0
@@ -0,0 +1,475 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Hermes platform protocol — hosted Hermes ($10) only.
5
+ *
6
+ * Steals the Codex app-server initialize → thread → turn lifecycle and
7
+ * Slack/NanoClaw named manager/child identity gates. Approvals stay on
8
+ * thumbgate.app / in_app_approval. No second harness, no Slack Marketplace,
9
+ * no Continuity / Mac-pair / phone leash.
10
+ *
11
+ * Sources:
12
+ * https://developers.openai.com/blog/codex-as-a-platform
13
+ * https://thenewstack.io/add-to-slack-agents/
14
+ */
15
+
16
+ const FORBIDDEN_IDENTITIES = Object.freeze(['foo', 'bar', 'shared', 'anonymous']);
17
+ const THREAD_MODES = Object.freeze(['exec', 'interactive', 'product']);
18
+ const CONSEQUENTIAL_ACTIONS = Object.freeze(['send', 'spend', 'prod', 'legal']);
19
+ const VALID_APPROVAL_SURFACES = Object.freeze(['thumbgate.app', 'in_app_approval']);
20
+ const INVALID_APPROVAL_SURFACES = Object.freeze([
21
+ 'slack_mention',
22
+ 'chat_toast',
23
+ 'mobile',
24
+ 'phone_leash',
25
+ ]);
26
+
27
+ function fail(reason, extra) {
28
+ return extra ? { ok: false, reason, ...extra } : { ok: false, reason };
29
+ }
30
+
31
+ function ok(extra) {
32
+ return extra ? { ok: true, ...extra } : { ok: true };
33
+ }
34
+
35
+ function isNamedIdentity(value) {
36
+ if (typeof value !== 'string') return false;
37
+ const id = value.trim();
38
+ if (!id) return false;
39
+ return !FORBIDDEN_IDENTITIES.includes(id);
40
+ }
41
+
42
+ function asScopeList(scopes) {
43
+ if (scopes == null) return null;
44
+ if (!Array.isArray(scopes)) return null;
45
+ return scopes.map((s) => String(s));
46
+ }
47
+
48
+ function isSubset(childScopes, parentScopes) {
49
+ const parent = new Set(parentScopes);
50
+ return childScopes.every((scope) => parent.has(scope));
51
+ }
52
+
53
+ class ProtocolError extends Error {
54
+ constructor(code, message) {
55
+ super(message || code);
56
+ this.name = 'ProtocolError';
57
+ this.code = code;
58
+ }
59
+ }
60
+
61
+ class HermesPlatformProtocol {
62
+ constructor() {
63
+ this.managers = new Map();
64
+ this.children = new Map();
65
+ this.connections = new Map();
66
+ this.threads = new Map();
67
+ this.turns = new Map();
68
+ this.approvals = new Map();
69
+ this._approvalSeq = 0;
70
+ this._threadSeq = 0;
71
+ this._turnSeq = 0;
72
+ }
73
+
74
+ registerManager({ managerId, scopes } = {}) {
75
+ if (!isNamedIdentity(managerId)) {
76
+ return fail('invalid_identity', { managerId: managerId || '' });
77
+ }
78
+ const scopeList = asScopeList(scopes);
79
+ if (scopeList == null) {
80
+ return fail('scope_missing');
81
+ }
82
+ const record = {
83
+ managerId,
84
+ scopes: Object.freeze([...scopeList]),
85
+ registeredAt: Date.now(),
86
+ };
87
+ this.managers.set(managerId, record);
88
+ return ok({ managerId, scopes: record.scopes });
89
+ }
90
+
91
+ mintChild({ managerId, agentId, scopes } = {}) {
92
+ if (!isNamedIdentity(managerId) || !isNamedIdentity(agentId)) {
93
+ return fail('invalid_identity', { managerId, agentId });
94
+ }
95
+ const manager = this.managers.get(managerId);
96
+ if (!manager) {
97
+ return fail('manager_missing', { managerId });
98
+ }
99
+ const scopeList = asScopeList(scopes);
100
+ if (scopeList == null) {
101
+ return fail('scope_missing');
102
+ }
103
+ if (!isSubset(scopeList, manager.scopes)) {
104
+ return fail('scope_exceeds_parent', { managerId, agentId });
105
+ }
106
+ const record = {
107
+ managerId,
108
+ agentId,
109
+ scopes: Object.freeze([...scopeList]),
110
+ mintedAt: Date.now(),
111
+ };
112
+ this.children.set(agentId, record);
113
+ return ok({ managerId, agentId, scopes: record.scopes });
114
+ }
115
+
116
+ initialize({ connectionId } = {}) {
117
+ if (!connectionId || typeof connectionId !== 'string') {
118
+ return fail('connection_required');
119
+ }
120
+ this.connections.set(connectionId, {
121
+ connectionId,
122
+ initialized: true,
123
+ initializedAt: Date.now(),
124
+ });
125
+ return ok({ connectionId, initialized: true });
126
+ }
127
+
128
+ _requireConnection(connectionId) {
129
+ if (!connectionId || !this.connections.has(connectionId)) {
130
+ return fail('not_initialized');
131
+ }
132
+ return null;
133
+ }
134
+
135
+ _getThread(threadId) {
136
+ return threadId ? this.threads.get(threadId) : undefined;
137
+ }
138
+
139
+ createThread({ connectionId, mode, recordId } = {}) {
140
+ const denied = this._requireConnection(connectionId);
141
+ if (denied) return denied;
142
+ if (!THREAD_MODES.includes(mode)) {
143
+ return fail('invalid_mode', { mode });
144
+ }
145
+ this._threadSeq += 1;
146
+ const threadId = `thr_${this._threadSeq}`;
147
+ const thread = {
148
+ threadId,
149
+ connectionId,
150
+ mode,
151
+ recordId: recordId || null,
152
+ archived: false,
153
+ route: 'primary',
154
+ tombstone: null,
155
+ pendingRefresh: false,
156
+ lastWriteTxnId: null,
157
+ };
158
+ this.threads.set(threadId, thread);
159
+ return ok({ threadId, mode, recordId: thread.recordId });
160
+ }
161
+
162
+ startTurn({ connectionId, threadId, input, recordId } = {}) {
163
+ const denied = this._requireConnection(connectionId);
164
+ if (denied) return denied;
165
+ const thread = this._getThread(threadId);
166
+ if (!thread || thread.connectionId !== connectionId) {
167
+ return fail('thread_missing', { threadId });
168
+ }
169
+ if (thread.archived) {
170
+ return fail('thread_archived', { threadId });
171
+ }
172
+ const openTurn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
173
+ if (openTurn && openTurn.open) {
174
+ // Never orphan an open turn behind a new pointer — the open-turn
175
+ // completion guard reads the pointer, so a replaced turn would be
176
+ // unreachable and silently defeat it under retries.
177
+ return fail('turn_already_open', { threadId, turnId: openTurn.turnId });
178
+ }
179
+ const effectiveRecord = recordId || thread.recordId;
180
+ if (thread.mode === 'product' && !effectiveRecord) {
181
+ return fail('record_required');
182
+ }
183
+ this._turnSeq += 1;
184
+ const turnId = `turn_${this._turnSeq}`;
185
+ const turn = {
186
+ turnId,
187
+ threadId,
188
+ connectionId,
189
+ input: input || '',
190
+ recordId: effectiveRecord || null,
191
+ open: true,
192
+ interrupted: false,
193
+ trustedPathTurningOn: false,
194
+ status: 'running',
195
+ verdict: null,
196
+ };
197
+ this.turns.set(turnId, turn);
198
+ thread.openTurnId = turnId;
199
+ if (effectiveRecord) thread.recordId = effectiveRecord;
200
+ return ok({ turnId, threadId, recordId: turn.recordId, open: true });
201
+ }
202
+
203
+ streamEvent({ connectionId, threadId, event } = {}) {
204
+ const denied = this._requireConnection(connectionId);
205
+ if (denied) return denied;
206
+ const thread = this._getThread(threadId);
207
+ if (!thread) return fail('thread_missing', { threadId });
208
+ const turn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
209
+ const payload = event && typeof event === 'object' ? event : {};
210
+ if (payload.status === 'live' || payload.verdict === 'fully_satisfactory') {
211
+ const trustedOn = payload.trustedPathTurningOn === true
212
+ || (turn && turn.trustedPathTurningOn === true);
213
+ if ((turn && turn.open) || trustedOn) {
214
+ return fail('claim_blocked', {
215
+ status: payload.status || null,
216
+ verdict: payload.verdict || null,
217
+ turnOpen: Boolean(turn && turn.open),
218
+ trustedPathTurningOn: trustedOn,
219
+ });
220
+ }
221
+ }
222
+ return ok({
223
+ threadId,
224
+ event: payload,
225
+ streamed: true,
226
+ });
227
+ }
228
+
229
+ interruptTurn({ connectionId, threadId } = {}) {
230
+ const denied = this._requireConnection(connectionId);
231
+ if (denied) return denied;
232
+ const thread = this._getThread(threadId);
233
+ if (!thread) return fail('thread_missing', { threadId });
234
+ const turn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
235
+ if (!turn || !turn.open) {
236
+ return fail('turn_not_open', { threadId });
237
+ }
238
+ turn.open = false;
239
+ turn.interrupted = true;
240
+ turn.status = 'interrupted';
241
+ thread.openTurnId = null;
242
+ return ok({ threadId, turnId: turn.turnId, interrupted: true });
243
+ }
244
+
245
+ archiveThread({ connectionId, threadId } = {}) {
246
+ const denied = this._requireConnection(connectionId);
247
+ if (denied) return denied;
248
+ const thread = this._getThread(threadId);
249
+ if (!thread) return fail('thread_missing', { threadId });
250
+ const turn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
251
+ if (turn && turn.open) {
252
+ turn.open = false;
253
+ turn.status = 'archived';
254
+ thread.openTurnId = null;
255
+ }
256
+ thread.archived = true;
257
+ return ok({ threadId, archived: true });
258
+ }
259
+
260
+ setTrustedPathTurningOn({ threadId, trustedPathTurningOn } = {}) {
261
+ const thread = this._getThread(threadId);
262
+ if (!thread) return fail('thread_missing', { threadId });
263
+ const turn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
264
+ if (turn) turn.trustedPathTurningOn = trustedPathTurningOn === true;
265
+ thread.trustedPathTurningOn = trustedPathTurningOn === true;
266
+ return ok({ threadId, trustedPathTurningOn: trustedPathTurningOn === true });
267
+ }
268
+
269
+ claimOutcome({ threadId, status, verdict, trustedPathTurningOn } = {}) {
270
+ const thread = this._getThread(threadId);
271
+ if (!thread) return fail('thread_missing', { threadId });
272
+ const turn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
273
+ const turnOpen = Boolean(turn && turn.open);
274
+ const trustedOn = trustedPathTurningOn === true
275
+ || (turn && turn.trustedPathTurningOn === true)
276
+ || thread.trustedPathTurningOn === true;
277
+ if (status === 'live' || verdict === 'fully_satisfactory') {
278
+ if (turnOpen || trustedOn) {
279
+ return fail('claim_blocked', {
280
+ turnOpen,
281
+ trustedPathTurningOn: trustedOn,
282
+ });
283
+ }
284
+ }
285
+ if (turn) {
286
+ if (status) turn.status = status;
287
+ if (verdict) turn.verdict = verdict;
288
+ }
289
+ return ok({ threadId, status: status || null, verdict: verdict || null });
290
+ }
291
+
292
+ endTurn({ threadId } = {}) {
293
+ const thread = this._getThread(threadId);
294
+ if (!thread) return fail('thread_missing', { threadId });
295
+ const turn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
296
+ if (!turn || !turn.open) return fail('turn_not_open', { threadId });
297
+ turn.open = false;
298
+ turn.status = 'completed';
299
+ thread.openTurnId = null;
300
+ return ok({ threadId, turnId: turn.turnId, open: false });
301
+ }
302
+
303
+ failoverRoute({ threadId, nextRoute } = {}) {
304
+ const thread = this._getThread(threadId);
305
+ if (!thread) return fail('thread_missing', { threadId });
306
+ if (!nextRoute || typeof nextRoute !== 'string') {
307
+ return fail('route_required');
308
+ }
309
+ thread.route = nextRoute;
310
+ // Same thread, no FAILED tombstone — the next route inherits the turn.
311
+ thread.tombstone = null;
312
+ const turn = thread.openTurnId ? this.turns.get(thread.openTurnId) : null;
313
+ return ok({
314
+ threadId,
315
+ route: nextRoute,
316
+ tombstone: null,
317
+ turnOpen: Boolean(turn && turn.open),
318
+ sameThread: true,
319
+ });
320
+ }
321
+
322
+ requestApproval({
323
+ connectionId,
324
+ threadId,
325
+ action,
326
+ surface,
327
+ handler,
328
+ actorId,
329
+ autoApprove,
330
+ } = {}) {
331
+ if (connectionId) {
332
+ const denied = this._requireConnection(connectionId);
333
+ if (denied) return denied;
334
+ }
335
+ if (!CONSEQUENTIAL_ACTIONS.includes(action)) {
336
+ return fail('not_consequential', { action });
337
+ }
338
+ const approvalHandler = handler || surface;
339
+ if (!approvalHandler) {
340
+ return fail('handler_missing');
341
+ }
342
+ if (
343
+ INVALID_APPROVAL_SURFACES.includes(approvalHandler)
344
+ || !VALID_APPROVAL_SURFACES.includes(approvalHandler)
345
+ ) {
346
+ return fail('invalid_approval_surface', { surface: approvalHandler });
347
+ }
348
+ const thread = threadId ? this._getThread(threadId) : null;
349
+ const mode = thread ? thread.mode : null;
350
+ if (autoApprove === true && mode === 'exec' && ['send', 'spend', 'prod'].includes(action)) {
351
+ return fail('exec_auto_approve_forbidden', { action, mode });
352
+ }
353
+ this._approvalSeq += 1;
354
+ const approvalId = `apr_${this._approvalSeq}`;
355
+ const record = {
356
+ approvalId,
357
+ threadId: threadId || null,
358
+ action,
359
+ surface: approvalHandler,
360
+ actorId: actorId || null,
361
+ status: 'pending',
362
+ event: 'approval-request',
363
+ };
364
+ this.approvals.set(approvalId, record);
365
+ return ok({
366
+ approvalId,
367
+ event: 'approval-request',
368
+ action,
369
+ surface: approvalHandler,
370
+ });
371
+ }
372
+
373
+ approve({ approvalId, approverId, autoApprove } = {}) {
374
+ const record = approvalId ? this.approvals.get(approvalId) : null;
375
+ if (!record) return fail('approval_missing', { approvalId });
376
+ if (!approverId) return fail('approver_required');
377
+ if (record.actorId && record.actorId === approverId) {
378
+ return fail('self_approve_forbidden', { actorId: record.actorId });
379
+ }
380
+ const thread = record.threadId ? this._getThread(record.threadId) : null;
381
+ const mode = thread ? thread.mode : null;
382
+ if (
383
+ autoApprove === true
384
+ && mode === 'exec'
385
+ && ['send', 'spend', 'prod'].includes(record.action)
386
+ ) {
387
+ return fail('exec_auto_approve_forbidden', { action: record.action, mode });
388
+ }
389
+ record.status = 'approved';
390
+ record.approverId = approverId;
391
+ if (thread && CONSEQUENTIAL_ACTIONS.includes(record.action)) {
392
+ thread.pendingRefresh = true;
393
+ }
394
+ return ok({ approvalId, status: 'approved', action: record.action });
395
+ }
396
+
397
+ applyApprovedWrite({ threadId, recordId, approvalId, skipRefresh, txnId } = {}) {
398
+ const thread = this._getThread(threadId);
399
+ if (!thread) return fail('thread_missing', { threadId });
400
+ // A write is only ever the tail of an approval. Without this the whole
401
+ // consequential-action gate is bypassable by anyone holding a thread id.
402
+ const approval = approvalId ? this.approvals.get(approvalId) : null;
403
+ if (!approval) return fail('approval_required', { approvalId: approvalId || null });
404
+ if (approval.status !== 'approved') {
405
+ return fail('approval_not_granted', { approvalId, status: approval.status });
406
+ }
407
+ if (approval.threadId && approval.threadId !== threadId) {
408
+ return fail('approval_thread_mismatch', { approvalId, threadId });
409
+ }
410
+ if (approval.consumed === true) {
411
+ return fail('approval_already_consumed', { approvalId });
412
+ }
413
+ if (skipRefresh === true) {
414
+ return fail('refresh_required');
415
+ }
416
+ approval.consumed = true;
417
+ thread.lastWriteTxnId = txnId || `txn_${Date.now()}`;
418
+ thread.pendingRefresh = true;
419
+ thread.recordId = recordId || thread.recordId;
420
+ return ok({
421
+ threadId,
422
+ recordId: thread.recordId,
423
+ approvalId,
424
+ txnId: thread.lastWriteTxnId,
425
+ refreshRequired: true,
426
+ });
427
+ }
428
+
429
+ refreshBusinessView({ threadId, recordId } = {}) {
430
+ const thread = this._getThread(threadId);
431
+ if (!thread) return fail('thread_missing', { threadId });
432
+ const target = recordId || thread.recordId;
433
+ if (!target) return fail('record_required');
434
+ thread.pendingRefresh = false;
435
+ thread.lastRefreshedRecordId = target;
436
+ return ok({ threadId, recordId: target, refreshed: true });
437
+ }
438
+
439
+ readBusinessView({ threadId, recordId } = {}) {
440
+ const thread = this._getThread(threadId);
441
+ if (!thread) return fail('thread_missing', { threadId });
442
+ if (thread.pendingRefresh) {
443
+ return fail('refresh_required');
444
+ }
445
+ return ok({
446
+ threadId,
447
+ recordId: recordId || thread.recordId,
448
+ view: 'current',
449
+ });
450
+ }
451
+
452
+ ingestInbound({ kind, text } = {}) {
453
+ if (kind !== 'mention' && kind !== 'chat') {
454
+ return fail('invalid_inbound_kind', { kind });
455
+ }
456
+ return ok({
457
+ kind,
458
+ text: text || '',
459
+ grants: Object.freeze([]),
460
+ send: false,
461
+ spend: false,
462
+ });
463
+ }
464
+ }
465
+
466
+ module.exports = {
467
+ HermesPlatformProtocol,
468
+ ProtocolError,
469
+ FORBIDDEN_IDENTITIES,
470
+ THREAD_MODES,
471
+ CONSEQUENTIAL_ACTIONS,
472
+ VALID_APPROVAL_SURFACES,
473
+ INVALID_APPROVAL_SURFACES,
474
+ isNamedIdentity,
475
+ };
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Hermes sync plane — hosted Hermes ($10) only, in-app.
5
+ *
6
+ * Steal from James Arthur / QCon / InfoQ "Why Fetch When You Can Sync?"
7
+ * (Electric + TanStack DB patterns) WITHOUT those dependencies:
8
+ * - Reactive read-path with offset+cursor (poll-without-cursor fails closed).
9
+ * - Authorized shape is the outer boundary; product reads need a subset.
10
+ * - Server-authoritative writes; optimistic only for non-consequential fields.
11
+ * - Match txnId on the read-path before dropping optimistic.
12
+ * - VPS + thumbgate.app are the source of truth; laptop sleep is the wedge.
13
+ *
14
+ * Source: https://www.infoq.com/presentations/local-first-sync-engine/
15
+ *
16
+ * Refuse: ElectricSQL, TanStack DB, Convex, Instant, Jazz, PowerSync, Zero,
17
+ * CRDTs, Yjs, Automerge, WebSocket stateful protocol, Continuity, Mac-pair,
18
+ * local-first as the public offer, Workers Paid.
19
+ */
20
+
21
+ const CONSEQUENTIAL_FIELDS = Object.freeze(['send', 'spend', 'prod', 'legal']);
22
+ const FORBIDDEN_TRUTH_HOSTS = Object.freeze([
23
+ 'mac',
24
+ 'laptop',
25
+ 'continuity',
26
+ 'mac-pair',
27
+ 'local-first',
28
+ ]);
29
+ const VALID_TRUTH_HOSTS = Object.freeze(['vps', 'thumbgate.app']);
30
+
31
+ function fail(reason, extra) {
32
+ return extra ? { ok: false, reason, ...extra } : { ok: false, reason };
33
+ }
34
+
35
+ function ok(extra) {
36
+ return extra ? { ok: true, ...extra } : { ok: true };
37
+ }
38
+
39
+ class HermesSyncPlane {
40
+ constructor() {
41
+ this.shapes = new Map();
42
+ this.events = [];
43
+ this.optimistic = new Map();
44
+ this.sourceOfTruth = 'vps';
45
+ this._offset = 0;
46
+ }
47
+
48
+ authorizeShape({ userId, threadId, recordId } = {}) {
49
+ if (!userId || !threadId) {
50
+ return fail('shape_unauthorized');
51
+ }
52
+ const key = `${userId}:${threadId}`;
53
+ this.shapes.set(key, { userId, threadId, recordId: recordId || null });
54
+ return ok({ userId, threadId, recordId: recordId || null });
55
+ }
56
+
57
+ _shapeFor(auth, threadId) {
58
+ if (!auth || !auth.userId) return null;
59
+ if (!threadId) return null;
60
+ return this.shapes.get(`${auth.userId}:${threadId}`) || null;
61
+ }
62
+
63
+ /**
64
+ * Reactive read-path. A status client MUST carry offset+cursor.
65
+ * hermes-control-plane poll-without-cursor is the Workers 90% leak anti-pattern.
66
+ */
67
+ readStatus({
68
+ offset,
69
+ cursor,
70
+ auth,
71
+ threadId,
72
+ recordId,
73
+ mode,
74
+ dumpAll,
75
+ turnOpen,
76
+ trustedPathTurningOn,
77
+ status,
78
+ verdict,
79
+ } = {}) {
80
+ if (cursor == null || cursor === '' || offset == null || offset === '') {
81
+ return fail('cursor_required');
82
+ }
83
+ if (!auth || !auth.userId) {
84
+ return fail('shape_unauthorized');
85
+ }
86
+ const shape = this._shapeFor(auth, threadId);
87
+ if (!shape) {
88
+ return fail('shape_unauthorized');
89
+ }
90
+ if (dumpAll === true) {
91
+ return fail('subset_required');
92
+ }
93
+ if (mode === 'product' && !recordId && !threadId) {
94
+ return fail('subset_required');
95
+ }
96
+ if (mode === 'product' && !recordId && !shape.recordId) {
97
+ return fail('subset_required');
98
+ }
99
+ // The shape is the authority. A caller may not widen past the record it
100
+ // was authorized for, nor omit the record to sweep the whole thread.
101
+ if (shape.recordId && recordId && recordId !== shape.recordId) {
102
+ return fail('record_unauthorized', { recordId, authorizedRecordId: shape.recordId });
103
+ }
104
+ const authorizedRecordId = shape.recordId || recordId || null;
105
+ if (status === 'live' || verdict === 'fully_satisfactory') {
106
+ if (turnOpen === true || trustedPathTurningOn === true) {
107
+ return fail('claim_blocked', { turnOpen: Boolean(turnOpen), trustedPathTurningOn: Boolean(trustedPathTurningOn) });
108
+ }
109
+ }
110
+ const from = Number(offset) || 0;
111
+ const events = this.events.filter((ev) => {
112
+ if (ev.offset < from) return false;
113
+ if (threadId && ev.threadId !== threadId) return false;
114
+ if (authorizedRecordId && ev.recordId && ev.recordId !== authorizedRecordId) return false;
115
+ return true;
116
+ });
117
+ return ok({
118
+ offset: from,
119
+ cursor,
120
+ recordId: authorizedRecordId,
121
+ events,
122
+ // Exclusive cursor: a client that polls again with this value must not
123
+ // re-receive the last event it already consumed.
124
+ nextOffset: this._offset + 1,
125
+ });
126
+ }
127
+
128
+ pollControlPlane(args = {}) {
129
+ // Named anti-pattern: dashboard poll loop. Fail closed without cursor.
130
+ return this.readStatus(args);
131
+ }
132
+
133
+ readRunEvents(args = {}) {
134
+ return this.readStatus(args);
135
+ }
136
+
137
+ appendEvent({ threadId, recordId, type, txnId, payload } = {}) {
138
+ this._offset += 1;
139
+ const event = {
140
+ offset: this._offset,
141
+ threadId: threadId || null,
142
+ recordId: recordId || null,
143
+ type: type || 'run',
144
+ txnId: txnId || null,
145
+ payload: payload || null,
146
+ };
147
+ this.events.push(event);
148
+ return ok({ event });
149
+ }
150
+
151
+ applyOptimistic({ txnId, field, value, action } = {}) {
152
+ const consequential = CONSEQUENTIAL_FIELDS.includes(action)
153
+ || CONSEQUENTIAL_FIELDS.includes(field);
154
+ if (consequential) {
155
+ return fail('server_authoritative', { action: action || field });
156
+ }
157
+ if (!txnId) return fail('txn_required');
158
+ this.optimistic.set(txnId, {
159
+ txnId,
160
+ field,
161
+ value,
162
+ applied: false,
163
+ local: true,
164
+ });
165
+ return ok({ txnId, field, value, local: true, applied: false });
166
+ }
167
+
168
+ serverWrite({ txnId, action, accepted, field, value, threadId, recordId } = {}) {
169
+ if (CONSEQUENTIAL_FIELDS.includes(action) && accepted !== true) {
170
+ const prior = this.optimistic.get(txnId);
171
+ if (prior) {
172
+ this.optimistic.delete(txnId);
173
+ }
174
+ return fail('server_rejected', {
175
+ txnId,
176
+ action,
177
+ applied: false,
178
+ rolledBack: Boolean(prior),
179
+ });
180
+ }
181
+ if (accepted !== true) {
182
+ const prior = this.optimistic.get(txnId);
183
+ if (prior) this.optimistic.delete(txnId);
184
+ return fail('server_rejected', {
185
+ txnId,
186
+ applied: false,
187
+ rolledBack: Boolean(prior),
188
+ });
189
+ }
190
+ this.appendEvent({ threadId, recordId, type: 'write', txnId, payload: { field, value, action } });
191
+ return ok({ txnId, applied: true, server: true });
192
+ }
193
+
194
+ dropOptimistic({ txnId, matchedTxnId } = {}) {
195
+ if (!txnId || !matchedTxnId || txnId !== matchedTxnId) {
196
+ return fail('txn_mismatch', { txnId, matchedTxnId });
197
+ }
198
+ const prior = this.optimistic.get(txnId);
199
+ this.optimistic.delete(txnId);
200
+ return ok({ txnId, dropped: Boolean(prior) });
201
+ }
202
+
203
+ confirmReadPath({ txnId, observedTxnId, skipMatch } = {}) {
204
+ if (skipMatch === true) {
205
+ return fail('txn_mismatch', { skipMatch: true });
206
+ }
207
+ return this.dropOptimistic({ txnId, matchedTxnId: observedTxnId });
208
+ }
209
+
210
+ setSourceOfTruth({ host } = {}) {
211
+ if (!host || FORBIDDEN_TRUTH_HOSTS.includes(host)) {
212
+ return fail('host_not_authoritative', { host: host || '' });
213
+ }
214
+ if (!VALID_TRUTH_HOSTS.includes(host)) {
215
+ return fail('host_not_authoritative', { host });
216
+ }
217
+ this.sourceOfTruth = host;
218
+ return ok({ host, sourceOfTruth: host });
219
+ }
220
+
221
+ overnightLoop({ laptopAwake } = {}) {
222
+ if (this.sourceOfTruth !== 'vps' && this.sourceOfTruth !== 'thumbgate.app') {
223
+ return fail('host_not_authoritative', { host: this.sourceOfTruth });
224
+ }
225
+ // Laptop sleep is the wedge; humans stay in loop because VPS writes and
226
+ // thumbgate.app syncs. Mac is never the source of truth.
227
+ return ok({
228
+ laptopAwake: laptopAwake === true,
229
+ wedge: laptopAwake === true ? null : 'laptop_sleep',
230
+ writer: 'vps',
231
+ sync: 'thumbgate.app',
232
+ });
233
+ }
234
+ }
235
+
236
+ module.exports = {
237
+ HermesSyncPlane,
238
+ CONSEQUENTIAL_FIELDS,
239
+ FORBIDDEN_TRUTH_HOSTS,
240
+ VALID_TRUTH_HOSTS,
241
+ };