@phyzess/pappra 0.0.0-stage → 0.1.0-beta.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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/integrations/skills/pappra/SKILL.md +68 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +30 -0
  6. package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
  7. package/assets/integrations/skills/pappra/references/operations.md +14 -0
  8. package/assets/manifest.json +4 -0
  9. package/dist/app/collaboration.d.ts +170 -0
  10. package/dist/app/collaboration.js +1650 -0
  11. package/dist/app/doctor.d.ts +9 -0
  12. package/dist/app/doctor.js +1030 -0
  13. package/dist/app/init.d.ts +143 -0
  14. package/dist/app/init.js +1111 -0
  15. package/dist/cli/commands/code.d.ts +16 -0
  16. package/dist/cli/commands/code.js +127 -0
  17. package/dist/cli/commands/collaboration.d.ts +13 -0
  18. package/dist/cli/commands/collaboration.js +186 -0
  19. package/dist/cli/commands/continuity.d.ts +29 -0
  20. package/dist/cli/commands/continuity.js +133 -0
  21. package/dist/cli/commands/docs.d.ts +11 -0
  22. package/dist/cli/commands/docs.js +133 -0
  23. package/dist/cli/commands/doctor.d.ts +14 -0
  24. package/dist/cli/commands/doctor.js +57 -0
  25. package/dist/cli/commands/host.d.ts +13 -0
  26. package/dist/cli/commands/host.js +125 -0
  27. package/dist/cli/commands/init.d.ts +51 -0
  28. package/dist/cli/commands/init.js +668 -0
  29. package/dist/cli/main.d.ts +2 -0
  30. package/dist/cli/main.js +10 -0
  31. package/dist/cli/output.d.ts +4 -0
  32. package/dist/cli/output.js +1 -0
  33. package/dist/cli/run.d.ts +2 -0
  34. package/dist/cli/run.js +597 -0
  35. package/dist/code/budget.d.ts +14 -0
  36. package/dist/code/budget.js +39 -0
  37. package/dist/code/exec.d.ts +23 -0
  38. package/dist/code/exec.js +133 -0
  39. package/dist/code/identity.d.ts +13 -0
  40. package/dist/code/identity.js +214 -0
  41. package/dist/code/index.d.ts +6 -0
  42. package/dist/code/index.js +3 -0
  43. package/dist/code/providers/codegraph.d.ts +59 -0
  44. package/dist/code/providers/codegraph.js +567 -0
  45. package/dist/code/registration.d.ts +9 -0
  46. package/dist/code/registration.js +293 -0
  47. package/dist/code/service.d.ts +53 -0
  48. package/dist/code/service.js +633 -0
  49. package/dist/code/text.d.ts +3 -0
  50. package/dist/code/text.js +202 -0
  51. package/dist/collab/admission.d.ts +450 -0
  52. package/dist/collab/admission.js +797 -0
  53. package/dist/collab/canonical.d.ts +26 -0
  54. package/dist/collab/canonical.js +136 -0
  55. package/dist/collab/contracts.d.ts +73 -0
  56. package/dist/collab/contracts.js +76 -0
  57. package/dist/collab/envelope.d.ts +52 -0
  58. package/dist/collab/envelope.js +241 -0
  59. package/dist/collab/fingerprint.d.ts +7 -0
  60. package/dist/collab/fingerprint.js +58 -0
  61. package/dist/collab/handoff.d.ts +350 -0
  62. package/dist/collab/handoff.js +513 -0
  63. package/dist/collab/index.d.ts +9 -0
  64. package/dist/collab/index.js +8 -0
  65. package/dist/collab/lifecycle.d.ts +449 -0
  66. package/dist/collab/lifecycle.js +534 -0
  67. package/dist/collab/messages.d.ts +148 -0
  68. package/dist/collab/messages.js +583 -0
  69. package/dist/collab/notify.d.ts +47 -0
  70. package/dist/collab/notify.js +140 -0
  71. package/dist/collab/preflight.d.ts +31 -0
  72. package/dist/collab/preflight.js +271 -0
  73. package/dist/collab/resources.d.ts +119 -0
  74. package/dist/collab/resources.js +451 -0
  75. package/dist/collab/store.d.ts +143 -0
  76. package/dist/collab/store.js +606 -0
  77. package/dist/collab/types.d.ts +354 -0
  78. package/dist/collab/types.js +1 -0
  79. package/dist/collab/validation.d.ts +10 -0
  80. package/dist/collab/validation.js +303 -0
  81. package/dist/continuity/context.d.ts +24 -0
  82. package/dist/continuity/context.js +179 -0
  83. package/dist/continuity/index.d.ts +3 -0
  84. package/dist/continuity/index.js +1 -0
  85. package/dist/continuity/paths.d.ts +37 -0
  86. package/dist/continuity/paths.js +258 -0
  87. package/dist/continuity/service.d.ts +59 -0
  88. package/dist/continuity/service.js +1258 -0
  89. package/dist/hosts/claude/native.d.ts +79 -0
  90. package/dist/hosts/claude/native.js +853 -0
  91. package/dist/hosts/codex/native.d.ts +345 -0
  92. package/dist/hosts/codex/native.js +1528 -0
  93. package/dist/hosts/codex/protocol.d.ts +51 -0
  94. package/dist/hosts/codex/protocol.js +42 -0
  95. package/dist/hosts/detect.d.ts +28 -0
  96. package/dist/hosts/detect.js +73 -0
  97. package/dist/hosts/index.d.ts +10 -0
  98. package/dist/hosts/index.js +10 -0
  99. package/dist/hosts/integration/bundle.d.ts +16 -0
  100. package/dist/hosts/integration/bundle.js +81 -0
  101. package/dist/hosts/integration/hook-entry.d.ts +1 -0
  102. package/dist/hosts/integration/hook-entry.js +68 -0
  103. package/dist/hosts/integration/hook.d.ts +32 -0
  104. package/dist/hosts/integration/hook.js +781 -0
  105. package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
  106. package/dist/hosts/integration/pi-extension-runtime.js +89 -0
  107. package/dist/hosts/integration/pi-template.d.ts +3 -0
  108. package/dist/hosts/integration/pi-template.js +5 -0
  109. package/dist/hosts/integration/service.d.ts +41 -0
  110. package/dist/hosts/integration/service.js +1381 -0
  111. package/dist/hosts/integration/support.d.ts +36 -0
  112. package/dist/hosts/integration/support.js +41 -0
  113. package/dist/hosts/integration/templates.d.ts +22 -0
  114. package/dist/hosts/integration/templates.js +70 -0
  115. package/dist/hosts/pi/protocol.d.ts +22 -0
  116. package/dist/hosts/pi/protocol.js +28 -0
  117. package/dist/hosts/pi/query-result.d.ts +22 -0
  118. package/dist/hosts/pi/query-result.js +710 -0
  119. package/dist/hosts/pi/rpc.d.ts +937 -0
  120. package/dist/hosts/pi/rpc.js +1621 -0
  121. package/dist/hosts/pilot.d.ts +485 -0
  122. package/dist/hosts/pilot.js +593 -0
  123. package/dist/index.d.ts +5 -0
  124. package/dist/index.js +4 -0
  125. package/dist/infra/fs.d.ts +27 -0
  126. package/dist/infra/fs.js +130 -0
  127. package/dist/infra/git.d.ts +60 -0
  128. package/dist/infra/git.js +358 -0
  129. package/dist/infra/owned-process-group.d.ts +173 -0
  130. package/dist/infra/owned-process-group.js +571 -0
  131. package/dist/install/apply.d.ts +65 -0
  132. package/dist/install/apply.js +663 -0
  133. package/dist/install/index.d.ts +5 -0
  134. package/dist/install/index.js +5 -0
  135. package/dist/install/ownership.d.ts +85 -0
  136. package/dist/install/ownership.js +430 -0
  137. package/dist/install/plan.d.ts +39 -0
  138. package/dist/install/plan.js +97 -0
  139. package/dist/install/rollback.d.ts +18 -0
  140. package/dist/install/rollback.js +544 -0
  141. package/dist/install/runtime.d.ts +76 -0
  142. package/dist/install/runtime.js +1145 -0
  143. package/dist/knowledge/catalog.d.ts +47 -0
  144. package/dist/knowledge/catalog.js +308 -0
  145. package/dist/knowledge/context.d.ts +28 -0
  146. package/dist/knowledge/context.js +61 -0
  147. package/dist/knowledge/index.d.ts +3 -0
  148. package/dist/knowledge/index.js +1 -0
  149. package/dist/knowledge/journal.d.ts +16 -0
  150. package/dist/knowledge/journal.js +109 -0
  151. package/dist/knowledge/lifecycle.d.ts +8 -0
  152. package/dist/knowledge/lifecycle.js +1001 -0
  153. package/dist/knowledge/paths.d.ts +37 -0
  154. package/dist/knowledge/paths.js +236 -0
  155. package/dist/knowledge/refs.d.ts +36 -0
  156. package/dist/knowledge/refs.js +267 -0
  157. package/dist/knowledge/service.d.ts +21 -0
  158. package/dist/knowledge/service.js +393 -0
  159. package/dist/knowledge/types.d.ts +171 -0
  160. package/dist/knowledge/types.js +1 -0
  161. package/dist/knowledge/write.d.ts +8 -0
  162. package/dist/knowledge/write.js +1223 -0
  163. package/dist/package-info.d.ts +6 -0
  164. package/dist/package-info.js +19 -0
  165. package/dist/schemas/code.d.ts +377 -0
  166. package/dist/schemas/code.js +145 -0
  167. package/dist/schemas/collaboration.d.ts +115 -0
  168. package/dist/schemas/collaboration.js +106 -0
  169. package/dist/schemas/continuity.d.ts +315 -0
  170. package/dist/schemas/continuity.js +199 -0
  171. package/dist/schemas/diagnostics.d.ts +28 -0
  172. package/dist/schemas/diagnostics.js +27 -0
  173. package/dist/schemas/doctor.d.ts +157 -0
  174. package/dist/schemas/doctor.js +75 -0
  175. package/dist/schemas/document.d.ts +644 -0
  176. package/dist/schemas/document.js +296 -0
  177. package/dist/schemas/errors.d.ts +177 -0
  178. package/dist/schemas/errors.js +176 -0
  179. package/dist/schemas/evidence.d.ts +24 -0
  180. package/dist/schemas/evidence.js +16 -0
  181. package/dist/schemas/host.d.ts +436 -0
  182. package/dist/schemas/host.js +201 -0
  183. package/dist/schemas/index.d.ts +15 -0
  184. package/dist/schemas/index.js +15 -0
  185. package/dist/schemas/init.d.ts +121 -0
  186. package/dist/schemas/init.js +94 -0
  187. package/dist/schemas/install.d.ts +213 -0
  188. package/dist/schemas/install.js +139 -0
  189. package/dist/schemas/machine.d.ts +43 -0
  190. package/dist/schemas/machine.js +96 -0
  191. package/dist/schemas/outcome.d.ts +23 -0
  192. package/dist/schemas/outcome.js +17 -0
  193. package/dist/schemas/paths.d.ts +19 -0
  194. package/dist/schemas/paths.js +63 -0
  195. package/dist/schemas/workspace.d.ts +120 -0
  196. package/dist/schemas/workspace.js +201 -0
  197. package/dist/workspace/config-file.d.ts +36 -0
  198. package/dist/workspace/config-file.js +237 -0
  199. package/dist/workspace/config.d.ts +28 -0
  200. package/dist/workspace/config.js +79 -0
  201. package/dist/workspace/discover.d.ts +34 -0
  202. package/dist/workspace/discover.js +195 -0
  203. package/dist/workspace/documents.d.ts +21 -0
  204. package/dist/workspace/documents.js +395 -0
  205. package/dist/workspace/ignore.d.ts +10 -0
  206. package/dist/workspace/ignore.js +48 -0
  207. package/dist/workspace/index.d.ts +7 -0
  208. package/dist/workspace/index.js +3 -0
  209. package/dist/workspace/repositories.d.ts +24 -0
  210. package/dist/workspace/repositories.js +411 -0
  211. package/dist/workspace/resolve.d.ts +24 -0
  212. package/dist/workspace/resolve.js +268 -0
  213. package/dist/workspace/types.d.ts +71 -0
  214. package/dist/workspace/types.js +1 -0
  215. package/dist/workspace/validation.d.ts +18 -0
  216. package/dist/workspace/validation.js +197 -0
  217. package/package.json +105 -4
  218. package/skills/pappra/SKILL.md +68 -0
  219. package/skills/pappra/references/hosts.md +30 -0
  220. package/skills/pappra/references/lifecycle.md +37 -0
  221. package/skills/pappra/references/operations.md +14 -0
@@ -0,0 +1,513 @@
1
+ import { text } from './validation.js';
2
+ import { createNonce, nowIso } from './store.js';
3
+ import { ACTIVE_STATES, TERMINAL_STATES, findAttempt, openUncertainty, } from './lifecycle.js';
4
+ // Writer and coordinator handoff.
5
+ //
6
+ // A handoff is a recorded sequence, not a message: stop the old writer, capture what
7
+ // exists, get the receiver's confirmation, then move ownership and bump the epoch.
8
+ // Overlapping resources are never handed over while the old writer's stop is unknown.
9
+ export const HANDOFF_KINDS = Object.freeze(['writer', 'coordinator']);
10
+ export const HANDOFF_STATES = Object.freeze([
11
+ 'requested',
12
+ 'old_writer_stopped',
13
+ 'transferring',
14
+ 'receiver_confirmed',
15
+ 'completed',
16
+ 'aborted',
17
+ ]);
18
+ const SEQUENCE = {
19
+ requested: 'old_writer_stopped',
20
+ old_writer_stopped: 'transferring',
21
+ transferring: 'receiver_confirmed',
22
+ receiver_confirmed: 'completed',
23
+ completed: null,
24
+ aborted: null,
25
+ };
26
+ export function findHandoff(record, handoffId) {
27
+ return ((record.handoffs ?? []).find((item) => item.handoff_id === handoffId) ??
28
+ null);
29
+ }
30
+ export function beginHandoff(record, { handoffId, kind, subtaskId = null, attempt = null, from, to, resources = [], ownerEpoch, clock = Date.now(), reason = null, }) {
31
+ if (!HANDOFF_KINDS.includes(kind))
32
+ return { ok: false, reason: 'invalid_handoff_kind' };
33
+ if (!text(handoffId))
34
+ return { ok: false, reason: 'handoff_id_required' };
35
+ if (findHandoff(record, handoffId))
36
+ return { ok: false, reason: 'handoff_exists' };
37
+ // Two open handoffs over the same resources (or the same writer attempt) would let the
38
+ // second one reuse the first one's stop evidence.
39
+ const open = (record.handoffs ?? []).filter((item) => !['completed', 'aborted'].includes(item.state));
40
+ for (const other of open) {
41
+ const sharedResources = (other.resources ?? []).filter((id) => (resources ?? []).includes(id));
42
+ const sameAttempt = kind === 'writer' &&
43
+ other.kind === 'writer' &&
44
+ other.subtask_id === subtaskId &&
45
+ other.attempt === attempt;
46
+ if (sharedResources.length || sameAttempt) {
47
+ return {
48
+ ok: false,
49
+ reason: 'overlapping_handoff',
50
+ other_handoff: other.handoff_id,
51
+ other_state: other.state,
52
+ shared_resources: sharedResources,
53
+ };
54
+ }
55
+ }
56
+ if (kind === 'coordinator') {
57
+ const otherCoordinator = open.find((item) => item.kind === 'coordinator');
58
+ if (otherCoordinator)
59
+ return {
60
+ ok: false,
61
+ reason: 'overlapping_handoff',
62
+ other_handoff: otherCoordinator.handoff_id,
63
+ other_state: otherCoordinator.state,
64
+ };
65
+ }
66
+ if (ownerEpoch !== record.coordinator?.owner_epoch) {
67
+ return {
68
+ ok: false,
69
+ reason: 'stale_epoch',
70
+ expected: record.coordinator?.owner_epoch ?? null,
71
+ actual: ownerEpoch ?? null,
72
+ };
73
+ }
74
+ if (kind === 'writer') {
75
+ const target = findAttempt(record, subtaskId, attempt);
76
+ if (!target)
77
+ return { ok: false, reason: 'unknown_attempt' };
78
+ target.ownership = 'transferring';
79
+ }
80
+ const at = nowIso(clock);
81
+ const entry = {
82
+ schema: 'harness-collab/handoff/1',
83
+ handoff_id: handoffId,
84
+ kind,
85
+ subtask_id: subtaskId,
86
+ attempt,
87
+ from,
88
+ to,
89
+ resources,
90
+ ownership_snapshot: (resources ?? []).map((claimId) => {
91
+ const claim = (record.claims ?? []).find((item) => item.claim_id === claimId);
92
+ return claim
93
+ ? {
94
+ claim_id: claimId,
95
+ holder: claim.holder ?? null,
96
+ resource_epoch: claim.resource_epoch ?? null,
97
+ released_at: claim.released_at ?? null,
98
+ }
99
+ : { claim_id: claimId, missing: true };
100
+ }),
101
+ state: 'requested',
102
+ owner_epoch: ownerEpoch,
103
+ created_at: at,
104
+ updated_at: at,
105
+ reason,
106
+ steps: [
107
+ { state: 'requested', at, actor: from?.agent_id ?? 'unattributed' },
108
+ ],
109
+ partial_outputs: [],
110
+ inflight: [],
111
+ pending_approvals: [],
112
+ confirmation_message_id: null,
113
+ };
114
+ record.handoffs = record.handoffs ?? [];
115
+ record.handoffs.push(entry);
116
+ if (kind === 'coordinator')
117
+ record.dispatch_frozen = true;
118
+ return { ok: true, handoff_id: handoffId, state: 'requested' };
119
+ }
120
+ function advance(entry, state, step, clock) {
121
+ let expected = SEQUENCE[entry.state];
122
+ // A coordinator handoff has no writer process to stop: the freeze plus the captured
123
+ // inventory is the barrier, so `old_writer_stopped` is skipped for that kind.
124
+ if (entry.kind === 'coordinator' &&
125
+ entry.state === 'requested' &&
126
+ state === 'transferring') {
127
+ expected = 'transferring';
128
+ }
129
+ if (expected !== state)
130
+ return {
131
+ ok: false,
132
+ reason: 'invalid_handoff_transition',
133
+ from: entry.state,
134
+ to: state,
135
+ expected,
136
+ };
137
+ const at = nowIso(clock);
138
+ entry.state = state;
139
+ entry.updated_at = at;
140
+ entry.steps.push({ state, at, ...step });
141
+ return { ok: true, state, at };
142
+ }
143
+ /**
144
+ * Record that the previous writer stopped. An `unknown` observation is refused: without
145
+ * confirmed stopping, overlapping resources stay where they are.
146
+ */
147
+ export function markOldWriterStopped(record, { handoffId, observation, evidenceRef = null, detail = null, actor, clock = Date.now(), }) {
148
+ const entry = findHandoff(record, handoffId);
149
+ if (!entry)
150
+ return { ok: false, reason: 'unknown_handoff' };
151
+ if (observation !== 'confirmed') {
152
+ openUncertainty(record, {
153
+ kind: 'cancel_unconfirmed',
154
+ subtaskId: entry.subtask_id,
155
+ attempt: entry.attempt,
156
+ detail: detail ??
157
+ 'old writer stop was not confirmed; resources are not transferred',
158
+ evidenceRef,
159
+ resolutionHint: 'query the native runner for the process/run state before retrying the handoff',
160
+ clock,
161
+ });
162
+ return { ok: false, reason: 'old_writer_not_stopped' };
163
+ }
164
+ if (!text(evidenceRef))
165
+ return { ok: false, reason: 'stop_evidence_required' };
166
+ return advance(entry, 'old_writer_stopped', { actor: actor ?? 'unattributed', evidence: evidenceRef, observation }, clock);
167
+ }
168
+ /**
169
+ * Bind the receiver's real identity once its native session is observed. A handoff may be
170
+ * requested before the receiver session exists; the identity is then a placeholder and the
171
+ * confirmation would not correlate. Rebinding requires native evidence and is recorded.
172
+ */
173
+ export function rebindReceiver(record, { handoffId, to, evidenceRef, actor, clock = Date.now(), }) {
174
+ const entry = findHandoff(record, handoffId);
175
+ if (!entry)
176
+ return { ok: false, reason: 'unknown_handoff' };
177
+ if (['completed', 'aborted'].includes(entry.state))
178
+ return { ok: false, reason: 'handoff_closed', state: entry.state };
179
+ if (entry.state === 'receiver_confirmed') {
180
+ // The confirmation belongs to the receiver that gave it. Rebinding after confirmation
181
+ // would let one party's acknowledgement authorise a transfer to another party.
182
+ return {
183
+ ok: false,
184
+ reason: 'already_confirmed',
185
+ detail: 'abort this handoff and open a new one instead of rebinding a confirmed receiver',
186
+ confirmed_receiver: entry.to,
187
+ };
188
+ }
189
+ if (!to?.agent_id || !to?.session_id)
190
+ return { ok: false, reason: 'invalid_receiver' };
191
+ if (!text(evidenceRef))
192
+ return { ok: false, reason: 'receiver_evidence_required' };
193
+ const previous = entry.to;
194
+ entry.to = {
195
+ agent_id: to.agent_id,
196
+ session_id: to.session_id,
197
+ harness: to.harness ?? entry.to?.harness ?? null,
198
+ };
199
+ entry.updated_at = nowIso(clock);
200
+ entry.steps.push({
201
+ state: entry.state,
202
+ at: entry.updated_at,
203
+ actor: actor ?? 'unattributed',
204
+ rebind_receiver: entry.to,
205
+ previous_receiver: previous,
206
+ evidence: evidenceRef,
207
+ });
208
+ return { ok: true, to: entry.to, previous };
209
+ }
210
+ export function markTransferring(record, { handoffId, partialOutputs = [], inflight = [], pendingApprovals = [], actor, clock = Date.now(), }) {
211
+ const entry = findHandoff(record, handoffId);
212
+ if (!entry)
213
+ return { ok: false, reason: 'unknown_handoff' };
214
+ if (!Array.isArray(partialOutputs) ||
215
+ !Array.isArray(inflight) ||
216
+ !Array.isArray(pendingApprovals)) {
217
+ return { ok: false, reason: 'invalid_inventory' };
218
+ }
219
+ const result = advance(entry, 'transferring', {
220
+ actor: actor ?? 'unattributed',
221
+ partial_outputs: partialOutputs.length,
222
+ inflight: inflight.length,
223
+ }, clock);
224
+ if (!result.ok)
225
+ return result;
226
+ entry.partial_outputs = partialOutputs;
227
+ entry.inflight = inflight;
228
+ entry.pending_approvals = pendingApprovals;
229
+ // Freeze the ownership this transfer is based on; completion must find the same state.
230
+ entry.ownership_snapshot = (entry.resources ?? []).map((claimId) => {
231
+ const claim = (record.claims ?? []).find((item) => item.claim_id === claimId);
232
+ return claim
233
+ ? {
234
+ claim_id: claimId,
235
+ holder: claim.holder ?? null,
236
+ resource_epoch: claim.resource_epoch ?? null,
237
+ released_at: claim.released_at ?? null,
238
+ }
239
+ : { claim_id: claimId, missing: true };
240
+ });
241
+ entry.snapshot_at = entry.updated_at;
242
+ return result;
243
+ }
244
+ /** The receiver's acknowledgement must be a stored message that replies to this handoff. */
245
+ export function confirmReceiver(record, { handoffId, message, clock = Date.now(), }) {
246
+ const entry = findHandoff(record, handoffId);
247
+ if (!entry)
248
+ return { ok: false, reason: 'unknown_handoff' };
249
+ if (!message)
250
+ return { ok: false, reason: 'confirmation_message_required' };
251
+ if (!['ACK', 'HANDOFF'].includes(message.type))
252
+ return {
253
+ ok: false,
254
+ reason: 'confirmation_type_invalid',
255
+ type: message.type,
256
+ };
257
+ if (message.from?.agent_id !== entry.to?.agent_id ||
258
+ message.from?.session_id !== entry.to?.session_id) {
259
+ return { ok: false, reason: 'confirmation_identity_mismatch' };
260
+ }
261
+ if (message.body?.handoff_id && message.body.handoff_id !== handoffId)
262
+ return { ok: false, reason: 'confirmation_handoff_mismatch' };
263
+ const result = advance(entry, 'receiver_confirmed', { actor: message.from.agent_id, message_id: message.message_id }, clock);
264
+ if (!result.ok)
265
+ return result;
266
+ entry.confirmation_message_id = message.message_id;
267
+ return result;
268
+ }
269
+ /**
270
+ * Complete the handoff: move ownership and raise the epoch. Old-epoch commands are
271
+ * rejected by the epoch check, so the previous owner cannot keep dispatching.
272
+ */
273
+ export function completeHandoff(record, { handoffId, actor, clock = Date.now(), }) {
274
+ const entry = findHandoff(record, handoffId);
275
+ if (!entry)
276
+ return { ok: false, reason: 'unknown_handoff' };
277
+ // The transfer must be based on the ownership it snapshotted, not on whatever the record
278
+ // says now. A handoff completed by anyone else, a released claim or a moved resource epoch
279
+ // all invalidate this one.
280
+ const snapshot = entry.ownership_snapshot ?? [];
281
+ const drift = [];
282
+ for (const expected of snapshot) {
283
+ const claim = (record.claims ?? []).find((item) => item.claim_id === expected.claim_id);
284
+ if (!claim || expected.missing)
285
+ drift.push({ claim_id: expected.claim_id, reason: 'claim_missing' });
286
+ else if (claim.released_at)
287
+ drift.push({ claim_id: expected.claim_id, reason: 'claim_released' });
288
+ else if ((claim.resource_epoch ?? null) !== (expected.resource_epoch ?? null))
289
+ drift.push({
290
+ claim_id: expected.claim_id,
291
+ reason: 'resource_epoch_changed',
292
+ expected: expected.resource_epoch,
293
+ actual: claim.resource_epoch,
294
+ });
295
+ else if (!expected.holder ||
296
+ claim.holder?.agent_id !== expected.holder.agent_id ||
297
+ claim.holder?.session_id !== expected.holder.session_id) {
298
+ drift.push({
299
+ claim_id: expected.claim_id,
300
+ reason: 'holder_changed',
301
+ expected: expected.holder,
302
+ actual: claim.holder,
303
+ });
304
+ }
305
+ else if (entry.from &&
306
+ (claim.holder?.agent_id !== entry.from.agent_id ||
307
+ claim.holder?.session_id !== entry.from.session_id)) {
308
+ // The handoff claims to move a resource away from `from`; if `from` is not the holder,
309
+ // this handoff is reusing someone else's ownership state.
310
+ drift.push({
311
+ claim_id: expected.claim_id,
312
+ reason: 'holder_is_not_from',
313
+ from: entry.from,
314
+ actual: claim.holder,
315
+ });
316
+ }
317
+ }
318
+ if (drift.length) {
319
+ entry.updated_at = nowIso(clock);
320
+ entry.steps.push({
321
+ state: entry.state,
322
+ at: entry.updated_at,
323
+ actor: actor ?? 'unattributed',
324
+ refused: 'ownership_changed',
325
+ drift,
326
+ });
327
+ openUncertainty(record, {
328
+ kind: 'late_result',
329
+ subtaskId: entry.subtask_id,
330
+ attempt: entry.attempt,
331
+ detail: `handoff ${handoffId} refused: the ownership it snapshotted changed`,
332
+ resolutionHint: 'reconcile the current holder before opening a new handoff',
333
+ clock,
334
+ });
335
+ return { ok: false, reason: 'ownership_changed', drift };
336
+ }
337
+ // A handoff is an act of the coordinator that requested it, so a coordinator change
338
+ // invalidates writer handoffs too, not just coordinator transfers.
339
+ if (record.coordinator?.owner_epoch !== entry.owner_epoch) {
340
+ return {
341
+ ok: false,
342
+ reason: 'stale_epoch',
343
+ expected: entry.owner_epoch,
344
+ actual: record.coordinator?.owner_epoch ?? null,
345
+ };
346
+ }
347
+ const result = advance(entry, 'completed', { actor: actor ?? 'unattributed' }, clock);
348
+ if (!result.ok)
349
+ return result;
350
+ const at = nowIso(clock);
351
+ if (entry.kind === 'coordinator') {
352
+ const previous = record.coordinator;
353
+ record.coordinator = {
354
+ ...entry.to,
355
+ owner_epoch: (previous?.owner_epoch ?? 0) + 1,
356
+ since: at,
357
+ previous: previous
358
+ ? {
359
+ agent_id: previous.agent_id,
360
+ session_id: previous.session_id,
361
+ owner_epoch: previous.owner_epoch,
362
+ }
363
+ : null,
364
+ };
365
+ entry.previous_coordinator = previous ?? null;
366
+ entry.owner_epoch = record.coordinator.owner_epoch;
367
+ // Dispatch stays frozen until the new coordinator explicitly releases it.
368
+ record.dispatch_frozen = true;
369
+ }
370
+ else {
371
+ entry.resource_transfers = [];
372
+ for (const claimId of entry.resources) {
373
+ const claim = (record.claims ?? []).find((item) => item.claim_id === claimId);
374
+ if (!claim)
375
+ continue;
376
+ const from = claim.holder;
377
+ claim.holder = {
378
+ agent_id: entry.to.agent_id,
379
+ session_id: entry.to.session_id,
380
+ harness: entry.to.harness ?? null,
381
+ };
382
+ claim.resource_epoch = (claim.resource_epoch ?? 0) + 1;
383
+ claim.transferred_from = from;
384
+ claim.transferred_at = at;
385
+ entry.resource_transfers.push({
386
+ claim_id: claimId,
387
+ resource_epoch: claim.resource_epoch,
388
+ from,
389
+ });
390
+ }
391
+ const target = findAttempt(record, entry.subtask_id, entry.attempt);
392
+ if (target) {
393
+ target.ownership = 'transferred';
394
+ target.assignee = {
395
+ agent_id: entry.to.agent_id,
396
+ session_id: entry.to.session_id,
397
+ harness: entry.to.harness ?? null,
398
+ };
399
+ target.updated_at = at;
400
+ target.transitions = target.transitions ?? [];
401
+ target.transitions.push({
402
+ from: target.state,
403
+ to: target.state,
404
+ at,
405
+ actor: actor ?? 'unattributed',
406
+ reason: `ownership transferred by ${handoffId}`,
407
+ evidence: entry.confirmation_message_id,
408
+ });
409
+ }
410
+ }
411
+ return result;
412
+ }
413
+ export function abortHandoff(record, { handoffId, actor, reason, clock = Date.now(), }) {
414
+ const entry = findHandoff(record, handoffId);
415
+ if (!entry)
416
+ return { ok: false, reason: 'unknown_handoff' };
417
+ if (entry.state === 'completed')
418
+ return { ok: false, reason: 'already_completed' };
419
+ const at = nowIso(clock);
420
+ entry.state = 'aborted';
421
+ entry.updated_at = at;
422
+ entry.steps.push({
423
+ state: 'aborted',
424
+ at,
425
+ actor: actor ?? 'unattributed',
426
+ reason: reason ?? null,
427
+ });
428
+ const target = findAttempt(record, entry.subtask_id, entry.attempt);
429
+ if (target && target.ownership === 'transferring') {
430
+ // Ownership returns to the previous holder: nothing moved.
431
+ target.ownership = 'retained';
432
+ target.updated_at = at;
433
+ }
434
+ return { ok: true, state: 'aborted' };
435
+ }
436
+ export function releaseDispatchFreeze(record, { actor, ownerEpoch, clock = Date.now(), }) {
437
+ if (ownerEpoch !== record.coordinator?.owner_epoch)
438
+ return {
439
+ ok: false,
440
+ reason: 'stale_epoch',
441
+ expected: record.coordinator?.owner_epoch ?? null,
442
+ actual: ownerEpoch ?? null,
443
+ };
444
+ record.dispatch_frozen = false;
445
+ record.dispatch_released_at = nowIso(clock);
446
+ record.dispatch_released_by = actor ?? 'unattributed';
447
+ return { ok: true, dispatch_frozen: false };
448
+ }
449
+ /** Snapshot of what a handing-over owner must account for. */
450
+ export function handoffInventory(record, { subtaskId = null, attempt = null, }) {
451
+ const attempts = (record.attempts ?? []).filter((item) => (subtaskId === null || item.subtask_id === subtaskId) &&
452
+ (attempt === null || item.attempt === attempt));
453
+ return {
454
+ inflight: attempts
455
+ .filter((item) => ACTIVE_STATES.includes(item.state))
456
+ .map((item) => ({
457
+ subtask_id: item.subtask_id,
458
+ attempt: item.attempt,
459
+ state: item.state,
460
+ native: item.native ?? null,
461
+ })),
462
+ closed: attempts
463
+ .filter((item) => TERMINAL_STATES.includes(item.state))
464
+ .map((item) => ({
465
+ subtask_id: item.subtask_id,
466
+ attempt: item.attempt,
467
+ state: item.state,
468
+ })),
469
+ pending_approvals: (record.attempts ?? [])
470
+ .filter((item) => item.state === 'needs_input' && item.awaiting === 'approval')
471
+ .map((item) => ({ subtask_id: item.subtask_id, attempt: item.attempt })),
472
+ uncertainty: (record.uncertainty ?? [])
473
+ .filter((item) => !item.resolved_at)
474
+ .map((item) => ({
475
+ uncertainty_id: item.uncertainty_id,
476
+ kind: item.kind,
477
+ subtask_id: item.subtask_id,
478
+ attempt: item.attempt,
479
+ })),
480
+ resources: (record.claims ?? [])
481
+ .filter((item) => !item.released_at)
482
+ .map((item) => ({
483
+ claim_id: item.claim_id,
484
+ key: item.key,
485
+ holder: item.holder,
486
+ resource_epoch: item.resource_epoch,
487
+ })),
488
+ // Admitted results with their output digests, so a receiver can see what exists.
489
+ results: (record.results ?? []).map((item) => ({
490
+ subtask_id: item.subtask_id,
491
+ attempt: item.attempt,
492
+ status: item.status,
493
+ classification: item.classification,
494
+ message_id: item.message_id,
495
+ outputs: item.outputs ?? [],
496
+ })),
497
+ handoffs: (record.handoffs ?? []).map((item) => ({
498
+ handoff_id: item.handoff_id,
499
+ kind: item.kind,
500
+ state: item.state,
501
+ })),
502
+ coordinator: record.coordinator ?? null,
503
+ epoch_semantics: {
504
+ owner_epoch: 'coordinator authority counter; only a coordinator handoff raises it',
505
+ state_revision: record.state_revision,
506
+ resource_epoch: 'per-resource claim counter; a writer handoff or a new claim raises it',
507
+ note: 'owner_epoch and resource_epoch are independent counters and do not have to match each other',
508
+ },
509
+ };
510
+ }
511
+ export function newHandoffId(prefix = 'handoff') {
512
+ return `${prefix}-${createNonce()}`;
513
+ }
@@ -0,0 +1,9 @@
1
+ export * as store from './store.js';
2
+ export * as canonical from './canonical.js';
3
+ export * as resources from './resources.js';
4
+ export * as envelope from './envelope.js';
5
+ export * as messages from './messages.js';
6
+ export * as lifecycle from './lifecycle.js';
7
+ export * as handoff from './handoff.js';
8
+ export * as notify from './notify.js';
9
+ export type * from './types.js';
@@ -0,0 +1,8 @@
1
+ export * as store from './store.js';
2
+ export * as canonical from './canonical.js';
3
+ export * as resources from './resources.js';
4
+ export * as envelope from './envelope.js';
5
+ export * as messages from './messages.js';
6
+ export * as lifecycle from './lifecycle.js';
7
+ export * as handoff from './handoff.js';
8
+ export * as notify from './notify.js';