work-ally 0.2.0-alpha.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 (172) hide show
  1. package/AGENTS.md +110 -0
  2. package/DASHBOARD.md +160 -0
  3. package/PRODUCT.md +113 -0
  4. package/README.md +403 -0
  5. package/ally.sh +171 -0
  6. package/bridge/src/approval-rules.ts +360 -0
  7. package/bridge/src/channel-delivery.ts +207 -0
  8. package/bridge/src/channel-types.ts +22 -0
  9. package/bridge/src/channels/fake/adapter.ts +31 -0
  10. package/bridge/src/channels/feishu/adapter.ts +411 -0
  11. package/bridge/src/channels/feishu/approvals.ts +6 -0
  12. package/bridge/src/channels/feishu/formatter.ts +276 -0
  13. package/bridge/src/channels/feishu/normalize.ts +368 -0
  14. package/bridge/src/codex-config.ts +52 -0
  15. package/bridge/src/config.ts +240 -0
  16. package/bridge/src/fake-runtime-client.ts +505 -0
  17. package/bridge/src/handoff-service.ts +494 -0
  18. package/bridge/src/logger.ts +194 -0
  19. package/bridge/src/memory-digest.ts +186 -0
  20. package/bridge/src/receiver-approval-autonomy.ts +158 -0
  21. package/bridge/src/receiver-control-core.ts +140 -0
  22. package/bridge/src/receiver-control-work-session.ts +218 -0
  23. package/bridge/src/receiver-control.ts +83 -0
  24. package/bridge/src/receiver-delivery.ts +136 -0
  25. package/bridge/src/receiver-helpers.ts +96 -0
  26. package/bridge/src/receiver-human-gate.ts +333 -0
  27. package/bridge/src/receiver-inbound-preflight.ts +162 -0
  28. package/bridge/src/receiver-recovery.ts +236 -0
  29. package/bridge/src/receiver-runtime-callbacks.ts +367 -0
  30. package/bridge/src/receiver-runtime-policy.ts +132 -0
  31. package/bridge/src/receiver-runtime-state.ts +124 -0
  32. package/bridge/src/receiver-support-actions.ts +189 -0
  33. package/bridge/src/receiver-thread-start.ts +57 -0
  34. package/bridge/src/receiver-turn-coordination.ts +94 -0
  35. package/bridge/src/receiver-turn-execution.ts +257 -0
  36. package/bridge/src/receiver-turn-failure.ts +143 -0
  37. package/bridge/src/receiver-turn-result.ts +185 -0
  38. package/bridge/src/receiver-turn-steer.ts +70 -0
  39. package/bridge/src/receiver-work-session.ts +76 -0
  40. package/bridge/src/receiver.ts +329 -0
  41. package/bridge/src/router.ts +62 -0
  42. package/bridge/src/runtime-client-agent-messages.ts +150 -0
  43. package/bridge/src/runtime-client-message-dispatch.ts +176 -0
  44. package/bridge/src/runtime-client-protocol.ts +411 -0
  45. package/bridge/src/runtime-client-request-ops.ts +56 -0
  46. package/bridge/src/runtime-client-run-turn.ts +158 -0
  47. package/bridge/src/runtime-client-thread-ops.ts +270 -0
  48. package/bridge/src/runtime-client-transport.ts +309 -0
  49. package/bridge/src/runtime-client-turn-poll.ts +224 -0
  50. package/bridge/src/runtime-client-turn-read.ts +185 -0
  51. package/bridge/src/runtime-client-turn-state.ts +105 -0
  52. package/bridge/src/runtime-client.ts +344 -0
  53. package/bridge/src/runtime-user-input.ts +403 -0
  54. package/bridge/src/scheduler.ts +239 -0
  55. package/bridge/src/server-handoff-command.ts +364 -0
  56. package/bridge/src/server-main.ts +80 -0
  57. package/bridge/src/server-routine-command.ts +60 -0
  58. package/bridge/src/server-routine-execution.ts +222 -0
  59. package/bridge/src/server-runtime-app-support.ts +107 -0
  60. package/bridge/src/server-runtime-app.ts +238 -0
  61. package/bridge/src/server-thread-sync-command.ts +63 -0
  62. package/bridge/src/server.ts +17 -0
  63. package/bridge/src/session-store-delivery.ts +220 -0
  64. package/bridge/src/session-store-human-gate.ts +380 -0
  65. package/bridge/src/session-store-inbound-acceptance.ts +66 -0
  66. package/bridge/src/session-store-meta.ts +134 -0
  67. package/bridge/src/session-store-turn-ledger.ts +272 -0
  68. package/bridge/src/session-store.ts +380 -0
  69. package/bridge/src/system-notify.ts +220 -0
  70. package/bridge/src/thread-sync.ts +200 -0
  71. package/bridge/src/translator.ts +494 -0
  72. package/bridge/src/types.ts +289 -0
  73. package/bridge/src/utils.ts +104 -0
  74. package/bridge/src/work-session-store.ts +471 -0
  75. package/docs/.gitkeep +0 -0
  76. package/docs/architecture/codex-feishu-bridge-proposal.md +2742 -0
  77. package/docs/completed/FEATURE-feishu-markdown-and-reply-support.md +327 -0
  78. package/docs/completed/README.md +21 -0
  79. package/docs/completed/SPEC-approval-autonomy-and-safe-defaults.md +205 -0
  80. package/docs/completed/SPEC-approval-batch-and-strict-reply-shortcuts.md +153 -0
  81. package/docs/completed/SPEC-conversation-noise-reduction-and-busy-input-gate.md +538 -0
  82. package/docs/completed/SPEC-engineering-sop-skillization.md +190 -0
  83. package/docs/completed/SPEC-faithful-bridge-core-thinning-v2.md +376 -0
  84. package/docs/completed/SPEC-faithful-bridge-core-thinning.md +1071 -0
  85. package/docs/completed/SPEC-group-chat-sender-identity.md +301 -0
  86. package/docs/completed/SPEC-middleware-exception-visibility.md +227 -0
  87. package/docs/completed/SPEC-nightly-memory-digest-visibility.md +121 -0
  88. package/docs/completed/SPEC-project-group-chat-human-centered-conversation-mapping.md +326 -0
  89. package/docs/completed/SPEC-remove-cli-persona-bootstrap.md +201 -0
  90. package/docs/developer-workflow.md +49 -0
  91. package/docs/implementation/SPEC-codex-same-machine-session-handoff-implementation.md +239 -0
  92. package/docs/implementation/test-coverage-map.md +363 -0
  93. package/docs/implementation/work-ally-implementation-guide.md +790 -0
  94. package/docs/issues/README.md +10 -0
  95. package/docs/issues/pending/ANALYSIS-ally-premature-recovery-notice-and-task-state-semantics-2026-03-18.md +295 -0
  96. package/docs/issues/resolved/ANALYSIS-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +466 -0
  97. package/docs/issues/resolved/ANALYSIS-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +261 -0
  98. package/docs/issues/resolved/ANALYSIS-codex-app-server-transport-disconnect-semantics-2026-03-14.md +606 -0
  99. package/docs/issues/resolved/ANALYSIS-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +348 -0
  100. package/docs/issues/resolved/ANALYSIS-runtime-turn-delivery-and-recovery-2026-03-14.md +603 -0
  101. package/docs/issues/resolved/ANALYSIS-self-test-gap-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +166 -0
  102. package/docs/issues/resolved/ANALYSIS-self-test-gap-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +186 -0
  103. package/docs/issues/resolved/ANALYSIS-self-test-gap-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +166 -0
  104. package/docs/issues/resolved/REPORT-ally-runtime-turn-delivery-3b42fb8-2026-03-15.md +373 -0
  105. package/docs/manual-acceptance.md +127 -0
  106. package/docs/ops-runbook.md +44 -0
  107. package/docs/planning/FEATURE-memory-system.md +748 -0
  108. package/docs/planning/SPEC-active-turn-steer-and-context-compaction-visibility.md +269 -0
  109. package/docs/planning/SPEC-approval-rules-inheritance-and-local-validation-lane.md +450 -0
  110. package/docs/planning/SPEC-assistant-persona-bootstrap.md +199 -0
  111. package/docs/planning/SPEC-assistant-rename.md +610 -0
  112. package/docs/planning/SPEC-bridge-app-server-protocol-alignment.md +667 -0
  113. package/docs/planning/SPEC-claude-runtime-host-for-work-ally.md +434 -0
  114. package/docs/planning/SPEC-cli-feishu-codex-session-unification.md +236 -0
  115. package/docs/planning/SPEC-codex-same-machine-session-handoff.md +873 -0
  116. package/docs/planning/SPEC-feishu-reaction-shortcuts.md +282 -0
  117. package/docs/planning/SPEC-local-stable-release-boundary.md +166 -0
  118. package/docs/planning/SPEC-managed-thread-entry-and-surface-mobility.md +862 -0
  119. package/docs/planning/SPEC-minimal-bridge-semantics-and-user-visible-surface.md +362 -0
  120. package/docs/planning/SPEC-npm-alpha-distribution-and-install-first-release.md +222 -0
  121. package/docs/planning/SPEC-remove-websocket-runtime-transport.md +364 -0
  122. package/docs/planning/SPEC-runtime-abstraction-phase-1.md +424 -0
  123. package/docs/planning/SPEC-runtime-connection-and-turn-recovery-semantics.md +274 -0
  124. package/docs/planning/SPEC-session-presence-and-state-visibility.md +397 -0
  125. package/docs/planning/SPEC-skill-first-capability-packaging.md +338 -0
  126. package/docs/planning/SPEC-stable-archive-contract.md +456 -0
  127. package/docs/planning/SPEC-supervised-start-boundary.md +127 -0
  128. package/docs/planning/SPEC-user-barrier-reduction-and-activation.md +832 -0
  129. package/docs/planning/ally-next.md +1278 -0
  130. package/docs/planning/assistant-workbench-spec.md +725 -0
  131. package/docs/planning/product-workbench.md +283 -0
  132. package/docs/product-onboarding.md +227 -0
  133. package/docs/product-spec-standard.md +528 -0
  134. package/docs/troubleshooting.md +45 -0
  135. package/docs/user-quickstart.md +46 -0
  136. package/internal/dispatch.sh +95 -0
  137. package/internal/lib/common.sh +1450 -0
  138. package/internal/modules/assistant/manage.sh +1312 -0
  139. package/internal/modules/bootstrap/setup.sh +144 -0
  140. package/internal/modules/config/init-env.sh +10 -0
  141. package/internal/modules/global/manage.sh +154 -0
  142. package/internal/modules/handoff/manage.sh +54 -0
  143. package/internal/modules/mcp/manage.sh +83 -0
  144. package/internal/modules/ops/logs.sh +76 -0
  145. package/internal/modules/routines/manage.sh +55 -0
  146. package/internal/modules/runtime/assistant-autosave.sh +26 -0
  147. package/internal/modules/runtime/restart.sh +6 -0
  148. package/internal/modules/runtime/start.sh +283 -0
  149. package/internal/modules/runtime/status.sh +194 -0
  150. package/internal/modules/runtime/stop.sh +55 -0
  151. package/internal/modules/runtime/supervisor.sh +216 -0
  152. package/internal/modules/runtime/update.sh +26 -0
  153. package/package.json +41 -0
  154. package/runtime/config/.gitkeep +0 -0
  155. package/runtime/host/.gitkeep +0 -0
  156. package/runtime/host/healthcheck-codex-app-server.ts +22 -0
  157. package/runtime/host/ping-pong-codex-app-server.ts +66 -0
  158. package/runtime/host/probe-codex-app-server.ts +115 -0
  159. package/skills/archive-reader/SKILL.md +9 -0
  160. package/skills/feishu-production-debug/SKILL.md +37 -0
  161. package/skills/feishu-production-debug/references/feishu-debug-order.md +49 -0
  162. package/skills/feishu-production-debug/references/platform-permission-baseline.md +23 -0
  163. package/skills/issue-to-spec-triage/SKILL.md +44 -0
  164. package/skills/issue-to-spec-triage/references/triage-rules.md +66 -0
  165. package/skills/memory-digest/SKILL.md +9 -0
  166. package/skills/post-implementation-closure/SKILL.md +39 -0
  167. package/skills/post-implementation-closure/references/closure-checklist.md +45 -0
  168. package/skills/post-implementation-closure/references/doc-drift-map.md +49 -0
  169. package/skills/product-spec/SKILL.md +244 -0
  170. package/templates/env.example +5 -0
  171. package/templates/routines/nightly-memory-digest.yaml +10 -0
  172. package/templates/workspace/AGENTS.md +26 -0
@@ -0,0 +1,220 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { appendNdjson, nowIso, readJsonFile, writeJsonFile } from './utils.ts';
4
+ import type { ConversationRef, InboundAcceptanceRecord, InboundReceipt, OutboundDeliveryRecord, SessionMeta } from './types.ts';
5
+
6
+ export interface ReceiptClaimResult {
7
+ status: 'claimed' | 'duplicate_processing' | 'duplicate_completed' | 'duplicate_superseded';
8
+ receipt: InboundReceipt;
9
+ }
10
+
11
+ interface SeenInboundRecord {
12
+ messageId: string;
13
+ firstSeenAt: string;
14
+ lastSeenAt: string;
15
+ }
16
+
17
+ export interface DeliveryStoreDeps {
18
+ ensureSessionDirs(ref: ConversationRef): string;
19
+ resolveSessionDir(ref: ConversationRef): string;
20
+ eventsFile(ref: ConversationRef): string;
21
+ receiptFile(ref: ConversationRef, messageId: string): string;
22
+ seenInboundFile(ref: ConversationRef, messageId: string): string;
23
+ outboundDeliveryFile(ref: ConversationRef, sourceKey: string): string;
24
+ getSession(ref: ConversationRef): SessionMeta | null;
25
+ getInboundAcceptance(ref: ConversationRef, messageId: string): InboundAcceptanceRecord | null;
26
+ noteInboundObserved(ref: ConversationRef, messageId: string): void;
27
+ }
28
+
29
+ function terminalReceiptOutcomeFor(
30
+ inboundAcceptance: InboundAcceptanceRecord | null,
31
+ ): InboundReceipt['outcome'] | null {
32
+ if (!inboundAcceptance?.acceptedAt) {
33
+ return null;
34
+ }
35
+ if (inboundAcceptance.executionStatus === 'completed') {
36
+ return 'success';
37
+ }
38
+ if (
39
+ inboundAcceptance.executionStatus === 'failed'
40
+ || inboundAcceptance.executionStatus === 'interrupted'
41
+ ) {
42
+ return 'failed';
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export function noteInboundReceipt(
48
+ deps: DeliveryStoreDeps,
49
+ ref: ConversationRef,
50
+ messageId: string,
51
+ ): ReceiptClaimResult {
52
+ deps.ensureSessionDirs(ref);
53
+ const file = deps.receiptFile(ref, messageId);
54
+ const seenFile = deps.seenInboundFile(ref, messageId);
55
+ const now = nowIso();
56
+ const existing = readJsonFile(file) as InboundReceipt | null;
57
+ if (existing) {
58
+ const receipt: InboundReceipt = {
59
+ ...existing,
60
+ updatedAt: existing.updatedAt || existing.createdAt || now,
61
+ };
62
+ return {
63
+ status: receipt.status === 'completed' ? 'duplicate_completed' : 'duplicate_processing',
64
+ receipt,
65
+ };
66
+ }
67
+
68
+ const seen = readJsonFile(seenFile) as SeenInboundRecord | null;
69
+ if (seen) {
70
+ writeJsonFile(seenFile, {
71
+ ...seen,
72
+ lastSeenAt: now,
73
+ });
74
+ const meta = deps.getSession(ref);
75
+ if (meta?.lastMessageId && meta.lastMessageId !== messageId) {
76
+ const latestAccepted = deps.getInboundAcceptance(ref, meta.lastMessageId);
77
+ const latestAcceptedAt = String(latestAccepted?.acceptedAt || '').trim();
78
+ if (latestAcceptedAt) {
79
+ const latestAcceptedTs = Date.parse(latestAcceptedAt);
80
+ const firstSeenTs = Date.parse(String(seen.firstSeenAt || ''));
81
+ if (Number.isFinite(latestAcceptedTs) && Number.isFinite(firstSeenTs) && latestAcceptedTs > firstSeenTs) {
82
+ const receipt = completeInboundReceipt(deps, ref, messageId);
83
+ return { status: 'duplicate_superseded', receipt };
84
+ }
85
+ }
86
+ }
87
+ const outcome = terminalReceiptOutcomeFor(deps.getInboundAcceptance(ref, messageId));
88
+ if (outcome) {
89
+ const receipt = completeInboundReceipt(deps, ref, messageId, outcome);
90
+ return { status: 'duplicate_completed', receipt };
91
+ }
92
+ } else {
93
+ writeJsonFile(seenFile, {
94
+ messageId,
95
+ firstSeenAt: now,
96
+ lastSeenAt: now,
97
+ } satisfies SeenInboundRecord);
98
+ }
99
+
100
+ const receipt: InboundReceipt = {
101
+ messageId,
102
+ status: 'processing',
103
+ createdAt: now,
104
+ updatedAt: now,
105
+ };
106
+ writeJsonFile(file, receipt);
107
+ deps.noteInboundObserved(ref, messageId);
108
+ return { status: 'claimed', receipt };
109
+ }
110
+
111
+ export function completeInboundReceipt(
112
+ deps: DeliveryStoreDeps,
113
+ ref: ConversationRef,
114
+ messageId: string,
115
+ outcome?: InboundReceipt['outcome'],
116
+ ): InboundReceipt {
117
+ deps.ensureSessionDirs(ref);
118
+ const file = deps.receiptFile(ref, messageId);
119
+ const now = nowIso();
120
+ const existing = readJsonFile(file) as InboundReceipt | null;
121
+ const receipt: InboundReceipt = existing
122
+ ? { ...existing, status: 'completed', updatedAt: now, outcome: outcome || existing.outcome }
123
+ : { messageId, status: 'completed', createdAt: now, updatedAt: now, outcome: outcome || undefined };
124
+ writeJsonFile(file, receipt);
125
+ return receipt;
126
+ }
127
+
128
+ export function clearInboundReceipt(deps: DeliveryStoreDeps, ref: ConversationRef, messageId: string): void {
129
+ const file = deps.receiptFile(ref, messageId);
130
+ if (!fs.existsSync(file)) {
131
+ return;
132
+ }
133
+ try {
134
+ fs.rmSync(file, { force: true });
135
+ } catch {
136
+ // noop
137
+ }
138
+ }
139
+
140
+ export function getInboundReceipt(
141
+ deps: DeliveryStoreDeps,
142
+ ref: ConversationRef,
143
+ messageId: string,
144
+ ): InboundReceipt | null {
145
+ return readJsonFile(deps.receiptFile(ref, messageId)) as InboundReceipt | null;
146
+ }
147
+
148
+ export function recordOutboundDelivery(
149
+ deps: DeliveryStoreDeps,
150
+ ref: ConversationRef,
151
+ record: Omit<OutboundDeliveryRecord, 'updatedAt'>,
152
+ ): OutboundDeliveryRecord {
153
+ deps.ensureSessionDirs(ref);
154
+ const file = deps.outboundDeliveryFile(ref, record.sourceKey);
155
+ const current = readJsonFile(file) as OutboundDeliveryRecord | null;
156
+ const updated: OutboundDeliveryRecord = {
157
+ ...record,
158
+ createdAt: current?.createdAt ?? record.createdAt,
159
+ updatedAt: nowIso(),
160
+ deliveredAt: current?.deliveredAt ?? record.deliveredAt ?? null,
161
+ status: current?.status === 'delivered' ? 'delivered' : record.status,
162
+ };
163
+ writeJsonFile(file, updated);
164
+ appendNdjson(deps.eventsFile(ref), {
165
+ type: 'outbound_delivery_recorded',
166
+ at: updated.updatedAt,
167
+ outbound_delivery: updated,
168
+ });
169
+ return updated;
170
+ }
171
+
172
+ export function markOutboundDelivered(
173
+ deps: DeliveryStoreDeps,
174
+ ref: ConversationRef,
175
+ sourceKey: string,
176
+ ): OutboundDeliveryRecord | null {
177
+ const file = deps.outboundDeliveryFile(ref, sourceKey);
178
+ const current = readJsonFile(file) as OutboundDeliveryRecord | null;
179
+ if (!current) {
180
+ return null;
181
+ }
182
+ const now = nowIso();
183
+ const updated: OutboundDeliveryRecord = {
184
+ ...current,
185
+ status: 'delivered',
186
+ updatedAt: now,
187
+ deliveredAt: current.deliveredAt || now,
188
+ };
189
+ writeJsonFile(file, updated);
190
+ appendNdjson(deps.eventsFile(ref), {
191
+ type: 'outbound_delivery_delivered',
192
+ at: now,
193
+ outbound_delivery: updated,
194
+ });
195
+ return updated;
196
+ }
197
+
198
+ export function getOutboundDelivery(
199
+ deps: DeliveryStoreDeps,
200
+ ref: ConversationRef,
201
+ sourceKey: string,
202
+ ): OutboundDeliveryRecord | null {
203
+ return readJsonFile(deps.outboundDeliveryFile(ref, sourceKey)) as OutboundDeliveryRecord | null;
204
+ }
205
+
206
+ export function listPendingOutboundDeliveries(
207
+ deps: DeliveryStoreDeps,
208
+ ref: ConversationRef,
209
+ ): OutboundDeliveryRecord[] {
210
+ const dir = path.join(deps.resolveSessionDir(ref), 'outbound-delivery');
211
+ if (!fs.existsSync(dir)) {
212
+ return [];
213
+ }
214
+ return fs.readdirSync(dir)
215
+ .filter((entry) => entry.endsWith('.json'))
216
+ .map((entry) => readJsonFile(path.join(dir, entry)) as OutboundDeliveryRecord | null)
217
+ .filter((record): record is OutboundDeliveryRecord => Boolean(record))
218
+ .filter((record) => record.status === 'pending')
219
+ .sort((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)));
220
+ }
@@ -0,0 +1,380 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { appendNdjson, nowIso, readJsonFile, sanitizeSegment, writeJsonFile } from './utils.ts';
4
+ import type { ApprovalRecord, ConversationRef, PendingUserInputRecord, SessionMeta, UserInputRequest } from './types.ts';
5
+
6
+ interface HumanGateStoreDeps {
7
+ sessionsDir: string;
8
+ ensureSessionDirs(ref: ConversationRef): string;
9
+ ensureSession(ref: ConversationRef): SessionMeta;
10
+ getSession(ref: ConversationRef): SessionMeta | null;
11
+ normalizeMeta(ref: ConversationRef, current: SessionMeta | null, now?: string): SessionMeta;
12
+ metaFile(ref: ConversationRef): string;
13
+ eventsFile(ref: ConversationRef): string;
14
+ approvalFile(ref: ConversationRef, approvalId: string): string;
15
+ pendingUserInputFile(ref: ConversationRef, requestId: string | number): string;
16
+ }
17
+
18
+ function normalizeApprovalRecord(approval: ApprovalRecord | null, meta?: SessionMeta | null): ApprovalRecord | null {
19
+ if (!approval) {
20
+ return null;
21
+ }
22
+ if (typeof approval.visible === 'boolean') {
23
+ return approval;
24
+ }
25
+ return {
26
+ ...approval,
27
+ visible: approval.status === 'pending' && Boolean(meta?.pendingApprovalIds.includes(approval.approvalId)),
28
+ };
29
+ }
30
+
31
+ function normalizePendingUserInputRecord(
32
+ record: PendingUserInputRecord | null,
33
+ meta?: SessionMeta | null,
34
+ ): PendingUserInputRecord | null {
35
+ if (!record) {
36
+ return null;
37
+ }
38
+ if (typeof record.visible === 'boolean') {
39
+ return record;
40
+ }
41
+ return {
42
+ ...record,
43
+ visible: record.status === 'pending' && meta?.pendingUserInputRequestId === String(record.requestId),
44
+ };
45
+ }
46
+
47
+ export function recordApproval(
48
+ deps: HumanGateStoreDeps,
49
+ ref: ConversationRef,
50
+ approval: ApprovalRecord,
51
+ options: { visible?: boolean } = {},
52
+ ): void {
53
+ deps.ensureSessionDirs(ref);
54
+ const meta = deps.ensureSession(ref);
55
+ const record: ApprovalRecord = {
56
+ ...approval,
57
+ visible: options.visible !== false,
58
+ };
59
+ if (record.visible && !meta.pendingApprovalIds.includes(record.approvalId)) {
60
+ meta.pendingApprovalIds.push(record.approvalId);
61
+ }
62
+ meta.updatedAt = nowIso();
63
+ writeJsonFile(deps.metaFile(ref), meta);
64
+ writeJsonFile(deps.approvalFile(ref, record.approvalId), record);
65
+ appendNdjson(deps.eventsFile(ref), {
66
+ type: 'approval_requested',
67
+ at: nowIso(),
68
+ approval: record,
69
+ });
70
+ }
71
+
72
+ export function markApprovalVisible(
73
+ deps: HumanGateStoreDeps,
74
+ ref: ConversationRef,
75
+ approvalId: string,
76
+ ): ApprovalRecord | null {
77
+ const meta = deps.ensureSession(ref);
78
+ const approval = normalizeApprovalRecord(
79
+ readJsonFile(deps.approvalFile(ref, approvalId)) as ApprovalRecord | null,
80
+ meta,
81
+ );
82
+ if (!approval || approval.status !== 'pending') {
83
+ return approval;
84
+ }
85
+ approval.visible = true;
86
+ writeJsonFile(deps.approvalFile(ref, approvalId), approval);
87
+ if (!meta.pendingApprovalIds.includes(approvalId)) {
88
+ meta.pendingApprovalIds.push(approvalId);
89
+ meta.updatedAt = nowIso();
90
+ writeJsonFile(deps.metaFile(ref), meta);
91
+ appendNdjson(deps.eventsFile(ref), {
92
+ type: 'approval_visible',
93
+ at: meta.updatedAt,
94
+ approvalId,
95
+ });
96
+ }
97
+ return approval;
98
+ }
99
+
100
+ export function resolveApproval(
101
+ deps: HumanGateStoreDeps,
102
+ ref: ConversationRef,
103
+ approvalId: string,
104
+ status: ApprovalRecord['status'],
105
+ ): ApprovalRecord | null {
106
+ const approval = normalizeApprovalRecord(
107
+ readJsonFile(deps.approvalFile(ref, approvalId)) as ApprovalRecord | null,
108
+ );
109
+ if (!approval) {
110
+ return null;
111
+ }
112
+ approval.status = status;
113
+ approval.visible = false;
114
+ writeJsonFile(deps.approvalFile(ref, approvalId), approval);
115
+ const meta = deps.ensureSession(ref);
116
+ meta.pendingApprovalIds = meta.pendingApprovalIds.filter((item) => item !== approvalId);
117
+ meta.updatedAt = nowIso();
118
+ writeJsonFile(deps.metaFile(ref), meta);
119
+ appendNdjson(deps.eventsFile(ref), {
120
+ type: 'approval_resolved',
121
+ at: nowIso(),
122
+ approvalId,
123
+ status,
124
+ });
125
+ return approval;
126
+ }
127
+
128
+ export function notePendingUserInput(
129
+ deps: HumanGateStoreDeps,
130
+ ref: ConversationRef,
131
+ request: UserInputRequest,
132
+ options: { visible?: boolean } = {},
133
+ ): PendingUserInputRecord {
134
+ deps.ensureSessionDirs(ref);
135
+ const meta = deps.ensureSession(ref);
136
+ const record: PendingUserInputRecord = {
137
+ ...request,
138
+ createdAt: nowIso(),
139
+ status: 'pending',
140
+ visible: options.visible !== false,
141
+ };
142
+ if (record.visible) {
143
+ meta.pendingUserInputRequestId = String(request.requestId);
144
+ }
145
+ meta.updatedAt = nowIso();
146
+ writeJsonFile(deps.metaFile(ref), meta);
147
+ writeJsonFile(deps.pendingUserInputFile(ref, request.requestId), record);
148
+ appendNdjson(deps.eventsFile(ref), {
149
+ type: 'user_input_requested',
150
+ at: nowIso(),
151
+ request: record,
152
+ });
153
+ return record;
154
+ }
155
+
156
+ export function markPendingUserInputVisible(
157
+ deps: HumanGateStoreDeps,
158
+ ref: ConversationRef,
159
+ requestId: string | number,
160
+ ): PendingUserInputRecord | null {
161
+ const meta = deps.ensureSession(ref);
162
+ const record = normalizePendingUserInputRecord(
163
+ readJsonFile(deps.pendingUserInputFile(ref, requestId)) as PendingUserInputRecord | null,
164
+ meta,
165
+ );
166
+ if (!record) {
167
+ return null;
168
+ }
169
+ record.visible = true;
170
+ writeJsonFile(deps.pendingUserInputFile(ref, requestId), record);
171
+ meta.pendingUserInputRequestId = String(record.requestId);
172
+ meta.updatedAt = nowIso();
173
+ writeJsonFile(deps.metaFile(ref), meta);
174
+ appendNdjson(deps.eventsFile(ref), {
175
+ type: 'user_input_visible',
176
+ at: meta.updatedAt,
177
+ requestId: String(record.requestId),
178
+ });
179
+ return record;
180
+ }
181
+
182
+ export function getPendingUserInput(
183
+ deps: HumanGateStoreDeps,
184
+ ref: ConversationRef,
185
+ ): PendingUserInputRecord | null {
186
+ const meta = deps.getSession(ref);
187
+ if (!meta?.pendingUserInputRequestId) {
188
+ return null;
189
+ }
190
+ return normalizePendingUserInputRecord(
191
+ readJsonFile(deps.pendingUserInputFile(ref, meta.pendingUserInputRequestId)) as PendingUserInputRecord | null,
192
+ meta,
193
+ );
194
+ }
195
+
196
+ export function findPendingUserInputByRequestId(
197
+ deps: HumanGateStoreDeps,
198
+ requestId: string | number,
199
+ ): { meta: SessionMeta; request: PendingUserInputRecord } | null {
200
+ const expected = String(requestId);
201
+ const entries = fs.readdirSync(deps.sessionsDir, { withFileTypes: true });
202
+ for (const entry of entries) {
203
+ if (!entry.isDirectory()) continue;
204
+ const sessionDir = path.join(deps.sessionsDir, entry.name);
205
+ const meta = readJsonFile(path.join(sessionDir, 'meta.json')) as SessionMeta | null;
206
+ if (!meta) {
207
+ continue;
208
+ }
209
+ const userInputsDir = path.join(sessionDir, 'user-inputs');
210
+ if (!fs.existsSync(userInputsDir)) {
211
+ continue;
212
+ }
213
+ for (const inputEntry of fs.readdirSync(userInputsDir)) {
214
+ if (!inputEntry.endsWith('.json')) {
215
+ continue;
216
+ }
217
+ const request = readJsonFile(path.join(userInputsDir, inputEntry)) as PendingUserInputRecord | null;
218
+ const normalized = normalizePendingUserInputRecord(request, meta);
219
+ if (!normalized || String(normalized.requestId) !== expected) {
220
+ continue;
221
+ }
222
+ return {
223
+ meta: deps.normalizeMeta(meta.conversationRef, meta, meta.updatedAt || nowIso()),
224
+ request: normalized,
225
+ };
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+
231
+ export function clearPendingUserInput(
232
+ deps: HumanGateStoreDeps,
233
+ ref: ConversationRef,
234
+ details?: { messageId?: string | null; answerPreview?: string | null; requestId?: string | number | null },
235
+ ): void {
236
+ const meta = deps.ensureSession(ref);
237
+ const requestId = details?.requestId !== undefined && details?.requestId !== null
238
+ ? String(details.requestId)
239
+ : meta.pendingUserInputRequestId;
240
+ if (details && 'messageId' in details) {
241
+ meta.lastMessageId = details.messageId || meta.lastMessageId;
242
+ }
243
+ if (requestId) {
244
+ const file = deps.pendingUserInputFile(ref, requestId);
245
+ if (fs.existsSync(file)) {
246
+ try {
247
+ fs.rmSync(file, { force: true });
248
+ } catch {
249
+ // noop
250
+ }
251
+ }
252
+ }
253
+ if (!requestId || meta.pendingUserInputRequestId === requestId) {
254
+ meta.pendingUserInputRequestId = null;
255
+ }
256
+ meta.updatedAt = nowIso();
257
+ writeJsonFile(deps.metaFile(ref), meta);
258
+ appendNdjson(deps.eventsFile(ref), {
259
+ type: 'user_input_resolved',
260
+ at: nowIso(),
261
+ requestId: requestId || null,
262
+ messageId: details?.messageId || null,
263
+ answerPreview: details?.answerPreview || null,
264
+ });
265
+ }
266
+
267
+ export function findApproval(
268
+ deps: HumanGateStoreDeps,
269
+ approvalId: string,
270
+ ): { meta: SessionMeta; approval: ApprovalRecord } | null {
271
+ const entries = fs.readdirSync(deps.sessionsDir, { withFileTypes: true });
272
+ for (const entry of entries) {
273
+ if (!entry.isDirectory()) continue;
274
+ const sessionDir = path.join(deps.sessionsDir, entry.name);
275
+ const meta = readJsonFile(path.join(sessionDir, 'meta.json')) as SessionMeta | null;
276
+ const approval = normalizeApprovalRecord(
277
+ readJsonFile(path.join(sessionDir, 'approvals', `${sanitizeSegment(approvalId)}.json`)) as ApprovalRecord | null,
278
+ meta,
279
+ );
280
+ if (!approval) continue;
281
+ if (!meta) continue;
282
+ return { meta: deps.normalizeMeta(meta.conversationRef, meta, meta.updatedAt || nowIso()), approval };
283
+ }
284
+ return null;
285
+ }
286
+
287
+ export function findApprovalByRequestId(
288
+ deps: HumanGateStoreDeps,
289
+ requestId: string | number,
290
+ ): { meta: SessionMeta; approval: ApprovalRecord } | null {
291
+ const expected = String(requestId);
292
+ const entries = fs.readdirSync(deps.sessionsDir, { withFileTypes: true });
293
+ for (const entry of entries) {
294
+ if (!entry.isDirectory()) continue;
295
+ const sessionDir = path.join(deps.sessionsDir, entry.name);
296
+ const approvalsDir = path.join(sessionDir, 'approvals');
297
+ if (!fs.existsSync(approvalsDir)) {
298
+ continue;
299
+ }
300
+ for (const approvalEntry of fs.readdirSync(approvalsDir)) {
301
+ if (!approvalEntry.endsWith('.json')) {
302
+ continue;
303
+ }
304
+ const meta = readJsonFile(path.join(sessionDir, 'meta.json')) as SessionMeta | null;
305
+ const approval = normalizeApprovalRecord(
306
+ readJsonFile(path.join(approvalsDir, approvalEntry)) as ApprovalRecord | null,
307
+ meta,
308
+ );
309
+ if (!approval || String(approval.requestId) !== expected) {
310
+ continue;
311
+ }
312
+ if (!meta) {
313
+ continue;
314
+ }
315
+ return { meta: deps.normalizeMeta(meta.conversationRef, meta, meta.updatedAt || nowIso()), approval };
316
+ }
317
+ }
318
+ return null;
319
+ }
320
+
321
+ export function getApproval(
322
+ deps: HumanGateStoreDeps,
323
+ ref: ConversationRef,
324
+ approvalId: string,
325
+ ): ApprovalRecord | null {
326
+ return normalizeApprovalRecord(
327
+ readJsonFile(deps.approvalFile(ref, approvalId)) as ApprovalRecord | null,
328
+ deps.getSession(ref),
329
+ );
330
+ }
331
+
332
+ export function listPendingApprovals(
333
+ deps: HumanGateStoreDeps,
334
+ ref: ConversationRef,
335
+ ): ApprovalRecord[] {
336
+ const meta = deps.getSession(ref);
337
+ const approvalsDir = path.join(deps.ensureSessionDirs(ref), 'approvals');
338
+ if (!fs.existsSync(approvalsDir)) {
339
+ return [];
340
+ }
341
+ return fs.readdirSync(approvalsDir)
342
+ .filter((entry) => entry.endsWith('.json'))
343
+ .map((entry) => normalizeApprovalRecord(
344
+ readJsonFile(path.join(approvalsDir, entry)) as ApprovalRecord | null,
345
+ meta,
346
+ ))
347
+ .filter((approval): approval is ApprovalRecord => Boolean(approval) && approval.status === 'pending')
348
+ .sort((left, right) => String(left.createdAt || '').localeCompare(String(right.createdAt || '')));
349
+ }
350
+
351
+ export function listHiddenPendingApprovals(
352
+ deps: HumanGateStoreDeps,
353
+ ref: ConversationRef,
354
+ turnId?: string | null,
355
+ ): ApprovalRecord[] {
356
+ return listPendingApprovals(deps, ref)
357
+ .filter((approval) => !approval.visible)
358
+ .filter((approval) => !turnId || approval.turnId === turnId);
359
+ }
360
+
361
+ export function findHiddenPendingUserInput(
362
+ deps: HumanGateStoreDeps,
363
+ ref: ConversationRef,
364
+ turnId?: string | null,
365
+ ): PendingUserInputRecord | null {
366
+ const meta = deps.getSession(ref);
367
+ const userInputsDir = path.join(deps.ensureSessionDirs(ref), 'user-inputs');
368
+ if (!fs.existsSync(userInputsDir)) {
369
+ return null;
370
+ }
371
+ return fs.readdirSync(userInputsDir)
372
+ .filter((entry) => entry.endsWith('.json'))
373
+ .map((entry) => normalizePendingUserInputRecord(
374
+ readJsonFile(path.join(userInputsDir, entry)) as PendingUserInputRecord | null,
375
+ meta,
376
+ ))
377
+ .filter((record): record is PendingUserInputRecord => Boolean(record) && record.status === 'pending' && !record.visible)
378
+ .filter((record) => !turnId || record.turnId === turnId)
379
+ .sort((left, right) => String(left.createdAt || '').localeCompare(String(right.createdAt || '')))[0] || null;
380
+ }
@@ -0,0 +1,66 @@
1
+ import { appendNdjson, nowIso, readJsonFile, writeJsonFile } from './utils.ts';
2
+ import type { ConversationRef, InboundAcceptanceRecord } from './types.ts';
3
+
4
+ export interface InboundAcceptanceStoreDeps {
5
+ ensureSessionDirs(ref: ConversationRef): string;
6
+ eventsFile(ref: ConversationRef): string;
7
+ inboundAcceptanceFile(ref: ConversationRef, messageId: string): string;
8
+ }
9
+
10
+ function normalizeInboundAcceptance(
11
+ messageId: string,
12
+ current: InboundAcceptanceRecord | null,
13
+ seed: Partial<InboundAcceptanceRecord> = {},
14
+ now = nowIso(),
15
+ ): InboundAcceptanceRecord {
16
+ return {
17
+ messageId,
18
+ threadId: current?.threadId ?? seed.threadId ?? null,
19
+ turnId: current?.turnId ?? seed.turnId ?? null,
20
+ createdAt: current?.createdAt || now,
21
+ updatedAt: now,
22
+ acceptedAt: current?.acceptedAt ?? seed.acceptedAt ?? null,
23
+ executionStatus: current?.executionStatus || seed.executionStatus || 'received',
24
+ };
25
+ }
26
+
27
+ export function updateInboundAcceptance(
28
+ deps: InboundAcceptanceStoreDeps,
29
+ ref: ConversationRef,
30
+ messageId: string,
31
+ seed: Partial<InboundAcceptanceRecord> = {},
32
+ mutate?: (record: InboundAcceptanceRecord, now: string) => void,
33
+ ): InboundAcceptanceRecord {
34
+ deps.ensureSessionDirs(ref);
35
+ const file = deps.inboundAcceptanceFile(ref, messageId);
36
+ const current = readJsonFile(file) as InboundAcceptanceRecord | null;
37
+ const now = nowIso();
38
+ const record = normalizeInboundAcceptance(messageId, current, seed, now);
39
+ mutate?.(record, now);
40
+ record.updatedAt = now;
41
+ writeJsonFile(file, record);
42
+ appendNdjson(deps.eventsFile(ref), {
43
+ type: 'inbound_acceptance_updated',
44
+ at: now,
45
+ inbound: record,
46
+ });
47
+ return record;
48
+ }
49
+
50
+ export function noteInboundObserved(
51
+ deps: InboundAcceptanceStoreDeps,
52
+ ref: ConversationRef,
53
+ messageId: string,
54
+ ): InboundAcceptanceRecord {
55
+ return updateInboundAcceptance(deps, ref, messageId, {}, (record) => {
56
+ record.executionStatus = record.executionStatus || 'received';
57
+ });
58
+ }
59
+
60
+ export function getInboundAcceptance(
61
+ deps: InboundAcceptanceStoreDeps,
62
+ ref: ConversationRef,
63
+ messageId: string,
64
+ ): InboundAcceptanceRecord | null {
65
+ return readJsonFile(deps.inboundAcceptanceFile(ref, messageId)) as InboundAcceptanceRecord | null;
66
+ }