@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2

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,797 @@
1
+ import { isAssignment, isCard } from './contracts.js';
2
+ import { parseJson } from './validation.js';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { nowIso, readRecord, transact } from './store.js';
7
+ import { preflight } from './preflight.js';
8
+ import { findAttempt, openUncertainty } from './lifecycle.js';
9
+ import { claimsOverlap, describeResource, pathRelation } from './canonical.js';
10
+ // Explicit launcher for one native pilot execution.
11
+ //
12
+ // It exists so the receiving harness's argv, cwd, tool allowance, timeout and identity
13
+ // are recorded next to the native events, and so no harness is invoked by accident: the
14
+ // command refuses to run without `--confirm-run true`.
15
+ //
16
+ // Version-pinned profiles (observed 2026-10-15):
17
+ // pi 1.2.0 : `--print --session-id <uuid> --tools <allowlist> --no-mcp`
18
+ // codex 0.162.1 : `exec --json -C <cwd> -s <sandbox>`; resume via `exec resume <thread>`
19
+ // claude 2.1.287 : `--print --output-format stream-json --setting-sources user
20
+ // --tools <allowlist> --strict-mcp-config --mcp-config {"mcpServers":{}}
21
+ // --settings {"disableAllHooks":true} --max-budget-usd <n>`
22
+ //
23
+ // No model or reasoning-effort override is supplied for any harness; the only limits are
24
+ // the per-run timeout, the tool allowlist and (for Claude) the requested API budget cap.
25
+ export const TOOL_PROFILES = {
26
+ pi: {
27
+ writer: 'read,bash,write,replace,replace_match',
28
+ // The Pi reviewer profile has no write/edit tool, but bash stays available so an
29
+ // independent verifier can run the frozen suite. That is a tool limit, not a sandbox.
30
+ reviewer: 'read,bash,anchor_grep',
31
+ },
32
+ claude: {
33
+ writer: 'Read,Edit,Write,Skill',
34
+ reviewer: 'Read,Skill',
35
+ },
36
+ };
37
+ /** The runner an assignment must name for a given harness; the launcher will not substitute one. */
38
+ const RUNNER_FOR_HARNESS = {
39
+ pi: 'pi-cli',
40
+ codex: 'codex-exec',
41
+ claude: 'claude-cli',
42
+ };
43
+ /** Whether the assignment can name the native session before the process starts. Pi and Claude
44
+ * accept an explicit session id; `codex exec` chooses the thread id itself. */
45
+ const PRE_BOUND_IDENTITY = {
46
+ pi: true,
47
+ codex: false,
48
+ claude: true,
49
+ };
50
+ /**
51
+ * Pre-dispatch admission. A launcher that started a native process without checking the
52
+ * assignment, the capability snapshot, the collaboration record and the actual resource
53
+ * ownership would be an automatic entry that bypasses the whole contract, so it refuses.
54
+ *
55
+ * Two modes exist:
56
+ * work the default; requires a runtime-ready capability card
57
+ * discovery the restricted path that produces the first runtime observation; it may use a
58
+ * declared card but must require no capability at all, so it cannot claim
59
+ * anything it has not observed
60
+ */
61
+ export function admitLaunch({ assignmentFile, capabilitiesFile, taskRoot, cwd, sessionId, harness = null, profile = null, launchKind = 'first', discovery = false, resumeSelector = null, now = Date.now(), maxAgeMinutes = 30, }) {
62
+ const read = (file) => {
63
+ const stat = fs.statSync(file);
64
+ if (!stat.isFile() || stat.size > 1024 * 1024)
65
+ throw new Error(`Unsupported JSON file: ${file}`);
66
+ return parseJson(fs.readFileSync(file, 'utf8'));
67
+ };
68
+ const assignment = read(assignmentFile);
69
+ if (!isAssignment(assignment))
70
+ throw new Error('Invalid assignment structure');
71
+ const card = read(capabilitiesFile);
72
+ if (!isCard(card))
73
+ throw new Error('Invalid capability card structure');
74
+ const checks = [];
75
+ const selector = resumeSelector ?? null;
76
+ // 1. the contract must describe the execution that is about to start
77
+ if (harness && assignment.assignee.harness !== harness) {
78
+ return {
79
+ ok: false,
80
+ reason: 'harness_contract_mismatch',
81
+ checks,
82
+ expected: assignment.assignee.harness,
83
+ actual: harness,
84
+ };
85
+ }
86
+ const expectedRunner = RUNNER_FOR_HARNESS[harness ?? assignment.assignee.harness];
87
+ if (assignment.runner?.name &&
88
+ expectedRunner &&
89
+ assignment.runner.name !== expectedRunner) {
90
+ return {
91
+ ok: false,
92
+ reason: 'runner_contract_mismatch',
93
+ checks,
94
+ expected: assignment.runner.name,
95
+ actual: expectedRunner,
96
+ };
97
+ }
98
+ if (profile &&
99
+ assignment.runner?.profile &&
100
+ assignment.runner.profile !== profile) {
101
+ return {
102
+ ok: false,
103
+ reason: 'profile_contract_mismatch',
104
+ checks,
105
+ expected: assignment.runner.profile,
106
+ actual: profile,
107
+ };
108
+ }
109
+ if (profile &&
110
+ !assignment.runner?.profile &&
111
+ assignment.runner?.mode === 'review-only' &&
112
+ profile !== 'reviewer') {
113
+ return {
114
+ ok: false,
115
+ reason: 'profile_contract_mismatch',
116
+ checks,
117
+ expected: 'reviewer',
118
+ actual: profile,
119
+ };
120
+ }
121
+ // 1c. A `first` launch that lets the CLI choose the native identity (Codex) cannot be
122
+ // justified by a card observed for another session: that path must be a discovery run.
123
+ const preBoundIdentity = PRE_BOUND_IDENTITY[harness ?? assignment.assignee.harness] === false
124
+ ? null
125
+ : assignment.assignee.session_id;
126
+ if (launchKind === 'first' && !preBoundIdentity && !discovery) {
127
+ return {
128
+ ok: false,
129
+ reason: 'new_session_requires_discovery',
130
+ checks,
131
+ detail: 'this harness chooses the native session id itself, so its first launch must be a discovery run and the work dispatch must continue the discovered identity',
132
+ };
133
+ }
134
+ // 2. the request must be internally consistent about starting versus continuing
135
+ if (launchKind === 'resume' && !selector) {
136
+ return {
137
+ ok: false,
138
+ reason: 'resume_selector_required',
139
+ checks,
140
+ detail: 'a resume launch must name the recorded native session',
141
+ };
142
+ }
143
+ if (launchKind === 'first' && selector) {
144
+ return {
145
+ ok: false,
146
+ reason: 'unexpected_resume_selector',
147
+ checks,
148
+ detail: 'a first launch must not carry a resume selector',
149
+ };
150
+ }
151
+ // 3. identity of the execution context
152
+ if (!path.isAbsolute(taskRoot ?? ''))
153
+ return {
154
+ ok: false,
155
+ reason: 'task_root_required',
156
+ checks,
157
+ detail: 'the launcher must verify the collaboration record before dispatch',
158
+ };
159
+ if (fs.realpathSync(assignment.assignee.cwd) !== fs.realpathSync(cwd)) {
160
+ return {
161
+ ok: false,
162
+ reason: 'cwd_mismatch',
163
+ checks,
164
+ detail: `launcher cwd ${cwd} is not the assigned cwd ${assignment.assignee.cwd}`,
165
+ };
166
+ }
167
+ if (sessionId && assignment.assignee.session_id !== sessionId) {
168
+ return {
169
+ ok: false,
170
+ reason: 'session_mismatch',
171
+ checks,
172
+ detail: `launcher session ${sessionId} is not the assigned session ${assignment.assignee.session_id}`,
173
+ };
174
+ }
175
+ const record = readRecord(taskRoot);
176
+ if (!record)
177
+ return {
178
+ ok: false,
179
+ reason: 'task_record_missing',
180
+ checks,
181
+ detail: taskRoot,
182
+ };
183
+ if (record.task_id !== assignment.task_id)
184
+ return {
185
+ ok: false,
186
+ reason: 'task_mismatch',
187
+ checks,
188
+ detail: `${record.task_id} != ${assignment.task_id}`,
189
+ };
190
+ if (record.dispatch_frozen)
191
+ return { ok: false, reason: 'dispatch_frozen', checks };
192
+ const currentEpoch = record.coordinator?.owner_epoch ?? null;
193
+ const assignmentEpoch = assignment.coordinator?.owner_epoch ?? null;
194
+ if (currentEpoch !== null &&
195
+ assignmentEpoch !== null &&
196
+ currentEpoch !== assignmentEpoch) {
197
+ return {
198
+ ok: false,
199
+ reason: 'stale_epoch',
200
+ checks,
201
+ expected: currentEpoch,
202
+ actual: assignmentEpoch,
203
+ };
204
+ }
205
+ // 4. the attempt must exist and be the one this contract was registered for
206
+ const attempt = findAttempt(record, assignment.assignment_id, assignment.attempt);
207
+ if (!attempt)
208
+ return {
209
+ ok: false,
210
+ reason: 'attempt_not_registered',
211
+ checks,
212
+ detail: `${assignment.assignment_id}#${assignment.attempt}`,
213
+ };
214
+ if (attempt.assignee &&
215
+ (attempt.assignee.agent_id !== assignment.assignee.agent_id ||
216
+ attempt.assignee.session_id !== assignment.assignee.session_id)) {
217
+ return {
218
+ ok: false,
219
+ reason: 'assignee_mismatch',
220
+ checks,
221
+ expected: attempt.assignee,
222
+ actual: assignment.assignee,
223
+ };
224
+ }
225
+ const liveAssignmentDigest = digestFile(assignmentFile);
226
+ if (attempt.assignment_digest &&
227
+ attempt.assignment_digest !== liveAssignmentDigest) {
228
+ return {
229
+ ok: false,
230
+ reason: 'assignment_digest_mismatch',
231
+ checks,
232
+ expected: attempt.assignment_digest,
233
+ actual: liveAssignmentDigest,
234
+ detail: 'the assignment differs from the one registered for this attempt',
235
+ };
236
+ }
237
+ if (attempt.assignment_ref &&
238
+ path.resolve(attempt.assignment_ref) !== path.resolve(assignmentFile)) {
239
+ return {
240
+ ok: false,
241
+ reason: 'assignment_ref_mismatch',
242
+ checks,
243
+ expected: attempt.assignment_ref,
244
+ actual: assignmentFile,
245
+ };
246
+ }
247
+ const liveInputDigest = assignment.inputs?.[0]
248
+ ? digestFile(assignment.inputs[0].ref)
249
+ : null;
250
+ if (attempt.input_revision &&
251
+ liveInputDigest &&
252
+ attempt.input_revision !== liveInputDigest) {
253
+ return {
254
+ ok: false,
255
+ reason: 'input_revision_mismatch',
256
+ checks,
257
+ expected: attempt.input_revision,
258
+ actual: liveInputDigest,
259
+ };
260
+ }
261
+ // 5. capability readiness, or the restricted discovery path
262
+ if (discovery) {
263
+ const requiredSkills = (assignment.required?.skills ?? []).length;
264
+ const requiredTools = (assignment.required?.tools ?? []).length;
265
+ if (requiredSkills || requiredTools) {
266
+ return {
267
+ ok: false,
268
+ reason: 'discovery_requires_no_capabilities',
269
+ checks,
270
+ required_skills: requiredSkills,
271
+ required_tools: requiredTools,
272
+ };
273
+ }
274
+ checks.push({
275
+ check: 'preflight',
276
+ status: 'skipped',
277
+ detail: 'discovery mode requires no capability',
278
+ });
279
+ }
280
+ else {
281
+ const result = preflight(assignment, card, { now, maxAgeMinutes });
282
+ checks.push({
283
+ check: 'preflight',
284
+ status: result.status,
285
+ blockers: result.blockers,
286
+ warnings: result.warnings,
287
+ });
288
+ if (result.status !== 'ready')
289
+ return {
290
+ ok: false,
291
+ reason: 'capability_preflight_not_ready',
292
+ checks,
293
+ preflight: result,
294
+ };
295
+ }
296
+ if (card.session_id !== assignment.assignee.session_id ||
297
+ (card.cwd !== undefined && !samePath(card.cwd, cwd))) {
298
+ return {
299
+ ok: false,
300
+ reason: 'capabilities_identity_mismatch',
301
+ checks,
302
+ };
303
+ }
304
+ // 6. may this attempt start at all
305
+ if (['completed', 'failed', 'cancelled'].includes(attempt.state)) {
306
+ return {
307
+ ok: false,
308
+ reason: 'attempt_already_closed',
309
+ checks,
310
+ state: attempt.state,
311
+ };
312
+ }
313
+ if (attempt.state === 'needs_input' && attempt.awaiting === 'approval') {
314
+ return {
315
+ ok: false,
316
+ reason: 'awaiting_approval',
317
+ checks,
318
+ detail: 'an approval block is answered by the user, not by a new dispatch',
319
+ };
320
+ }
321
+ if (hasPendingCancellation(record, assignment.assignment_id, assignment.attempt)) {
322
+ return {
323
+ ok: false,
324
+ reason: 'cancel_unconfirmed',
325
+ checks,
326
+ detail: 'native stop must be reconciled before any new launch or continuation',
327
+ };
328
+ }
329
+ const observation = attempt.native?.observation ?? 'unknown';
330
+ if (attempt.state === 'unknown' ||
331
+ (attempt.state === 'running' && observation !== 'confirmed')) {
332
+ return {
333
+ ok: false,
334
+ reason: 'attempt_state_unknown',
335
+ checks,
336
+ detail: 'the native state of this attempt is unconfirmed; reconcile it before dispatching anything',
337
+ };
338
+ }
339
+ if (attempt.state === 'running' && launchKind !== 'resume') {
340
+ return {
341
+ ok: false,
342
+ reason: 'attempt_already_running',
343
+ checks,
344
+ detail: 'a running attempt may only be continued with --launch-kind resume',
345
+ };
346
+ }
347
+ if (launchKind === 'first' && attempt.native?.run_id) {
348
+ return {
349
+ ok: false,
350
+ reason: 'already_launched',
351
+ checks,
352
+ detail: `attempt already has native run ${attempt.native.run_id}`,
353
+ };
354
+ }
355
+ // A native session is the instance, not the attempt: the run discovered by an earlier,
356
+ // closed attempt of the same subtask is a legitimate continuation target. That is what makes
357
+ // "discovery → work" a supported sequence instead of a manual state edit.
358
+ const resumable = [
359
+ {
360
+ attempt: attempt.attempt,
361
+ session_id: attempt.native?.session_id ?? null,
362
+ run_id: attempt.native?.run_id ?? null,
363
+ },
364
+ ...(record.attempts ?? [])
365
+ .filter((item) => item.subtask_id === assignment.assignment_id &&
366
+ item.attempt !== assignment.attempt &&
367
+ ['completed', 'failed', 'cancelled'].includes(item.state))
368
+ .map((item) => ({
369
+ attempt: item.attempt,
370
+ session_id: item.native?.session_id ?? null,
371
+ run_id: item.native?.run_id ?? null,
372
+ })),
373
+ ].filter((item) => item.session_id || item.run_id);
374
+ if (launchKind === 'resume' && !resumable.length) {
375
+ return {
376
+ ok: false,
377
+ reason: 'nothing_to_resume',
378
+ checks,
379
+ detail: 'neither this attempt nor a closed earlier attempt of the subtask has a recorded native run',
380
+ };
381
+ }
382
+ if (launchKind !== 'resume' &&
383
+ !['assigned', 'accepted'].includes(attempt.state)) {
384
+ return {
385
+ ok: false,
386
+ reason: 'attempt_not_startable',
387
+ checks,
388
+ state: attempt.state,
389
+ };
390
+ }
391
+ if (selector) {
392
+ const recorded = [
393
+ ...new Set(resumable
394
+ .flatMap((item) => [item.session_id, item.run_id])
395
+ .filter(Boolean)),
396
+ ];
397
+ if (!recorded.includes(selector)) {
398
+ return {
399
+ ok: false,
400
+ reason: 'resume_selector_mismatch',
401
+ checks,
402
+ expected: recorded,
403
+ actual: selector,
404
+ };
405
+ }
406
+ }
407
+ // 7. the resources the contract asks for must actually be held by this assignee
408
+ const claims = (record.claims ?? []).filter((item) => !item.released_at);
409
+ const resourceChecks = [];
410
+ for (const resource of assignment.resources ?? []) {
411
+ const descriptor = describeResource(describeRequest(resource));
412
+ // Containment, not symmetric overlap: holding a child file does not grant the parent
413
+ // directory, while holding the directory does grant everything inside it.
414
+ const covering = claims.filter((item) => claimCovers(item, descriptor));
415
+ if (resource.access === 'write') {
416
+ const held = covering.find((item) => item.access === 'write' &&
417
+ item.holder?.agent_id === assignment.assignee.agent_id &&
418
+ item.holder?.session_id === assignment.assignee.session_id &&
419
+ (item.subtask_id ?? null) === assignment.assignment_id &&
420
+ (item.attempt ?? null) === assignment.attempt);
421
+ if (!held) {
422
+ resourceChecks.push({
423
+ resource: descriptor.key,
424
+ access: 'write',
425
+ status: 'not_held',
426
+ });
427
+ return {
428
+ ok: false,
429
+ reason: 'resource_not_held',
430
+ checks,
431
+ resources: resourceChecks,
432
+ detail: `no active write claim covering ${descriptor.key} held by ${assignment.assignee.agent_id}/${assignment.assignee.session_id} for ${assignment.assignment_id}#${assignment.attempt}`,
433
+ };
434
+ }
435
+ if (held.resource_epoch !== resource.resource_epoch) {
436
+ resourceChecks.push({
437
+ resource: descriptor.key,
438
+ access: 'write',
439
+ status: 'epoch_mismatch',
440
+ expected: resource.resource_epoch,
441
+ actual: held.resource_epoch,
442
+ });
443
+ return {
444
+ ok: false,
445
+ reason: 'resource_epoch_mismatch',
446
+ checks,
447
+ resources: resourceChecks,
448
+ expected: resource.resource_epoch,
449
+ actual: held.resource_epoch,
450
+ };
451
+ }
452
+ resourceChecks.push({
453
+ resource: descriptor.key,
454
+ access: 'write',
455
+ status: 'held',
456
+ resource_epoch: held.resource_epoch,
457
+ claim_id: held.claim_id,
458
+ });
459
+ }
460
+ else {
461
+ // Reading overlaps symmetrically: a writer holding a child file conflicts with a reader of
462
+ // the whole directory, so the containment rule is only for issuing write authority.
463
+ const blocker = claims
464
+ .filter((item) => claimsOverlap(item, descriptor))
465
+ .find((item) => item.access === 'write' &&
466
+ !(item.holder?.agent_id === assignment.assignee.agent_id &&
467
+ item.holder?.session_id === assignment.assignee.session_id));
468
+ if (blocker) {
469
+ resourceChecks.push({
470
+ resource: descriptor.key,
471
+ access: 'read',
472
+ status: 'write_held_by_other',
473
+ held_by: blocker.holder,
474
+ });
475
+ return {
476
+ ok: false,
477
+ reason: 'resource_write_held_by_other',
478
+ checks,
479
+ resources: resourceChecks,
480
+ };
481
+ }
482
+ resourceChecks.push({
483
+ resource: descriptor.key,
484
+ access: 'read',
485
+ status: 'clear',
486
+ });
487
+ }
488
+ }
489
+ checks.push({ check: 'resources', status: 'ok', resources: resourceChecks });
490
+ checks.push({
491
+ check: 'record',
492
+ status: 'ok',
493
+ state_revision: record.state_revision,
494
+ owner_epoch: currentEpoch,
495
+ attempt_state: attempt.state,
496
+ launch_kind: launchKind,
497
+ mode: discovery ? 'discovery' : 'work',
498
+ });
499
+ return {
500
+ ok: true,
501
+ checks,
502
+ state_revision: record.state_revision,
503
+ owner_epoch: currentEpoch,
504
+ attempt_state: attempt.state,
505
+ resources: resourceChecks,
506
+ mode: discovery ? 'discovery' : 'work',
507
+ launch_kind: launchKind,
508
+ resume_selector: selector,
509
+ pre_bound_identity: preBoundIdentity,
510
+ expected_identity: launchKind === 'resume' ? selector : preBoundIdentity,
511
+ capability_evidence: discovery ? 'declared' : 'runtime-bound',
512
+ assignment_digest: liveAssignmentDigest,
513
+ input_revision: liveInputDigest,
514
+ };
515
+ }
516
+ function samePath(left, right) {
517
+ try {
518
+ return fs.realpathSync(left) === fs.realpathSync(right);
519
+ }
520
+ catch {
521
+ return false;
522
+ }
523
+ }
524
+ /** True when an existing claim covers the requested range (claim is equal to or above it). */
525
+ export function hasPendingCancellation(record, subtaskId, attempt) {
526
+ if ((record.uncertainty ?? []).some((item) => item.kind === 'cancel_unconfirmed' &&
527
+ !item.resolved_at &&
528
+ item.subtask_id === subtaskId &&
529
+ item.attempt === attempt))
530
+ return true;
531
+ const holder = findAttempt(record, subtaskId, attempt)?.assignee;
532
+ return (record.claims ?? []).some((claim) => !claim.released_at &&
533
+ claim.cancel_pending &&
534
+ holder &&
535
+ claim.holder?.agent_id === holder.agent_id &&
536
+ claim.holder?.session_id === holder.session_id &&
537
+ (claim.subtask_id == null || claim.subtask_id === subtaskId) &&
538
+ (claim.attempt == null || claim.attempt === attempt));
539
+ }
540
+ export function claimCovers(claim, requested) {
541
+ if (claim.key === requested.key)
542
+ return true;
543
+ if (!claim.path || !requested.path)
544
+ return false;
545
+ const relation = pathRelation(claim.path, requested.path);
546
+ return relation === 'same' || relation === 'ancestor';
547
+ }
548
+ function describeRequest(resource) {
549
+ return {
550
+ kind: resource.path ? 'path' : (resource.kind ?? 'path'),
551
+ path: resource.path,
552
+ ...(resource.port === undefined ? {} : { port: resource.port }),
553
+ };
554
+ }
555
+ function digestFile(file) {
556
+ return `sha256:${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`;
557
+ }
558
+ /**
559
+ * Atomically reserve the launch. A second launcher cannot reserve the same attempt, and the
560
+ * reservation distinguishes a first launch from a continuation, so "already started" is a
561
+ * recorded fact rather than an assumption.
562
+ */
563
+ export function reserveLaunch(taskRoot, { reservation, expectRevision = undefined, clock = Date.now(), }) {
564
+ const outcome = transact(taskRoot, {
565
+ actor: reservation.holder?.agent_id ?? 'launcher',
566
+ purpose: 'launch-reserve',
567
+ expectRevision,
568
+ clock,
569
+ apply: (record) => {
570
+ const attempt = findAttempt(record, reservation.subtask_id, reservation.attempt);
571
+ if (!attempt)
572
+ return { ok: false, reason: 'attempt_not_registered' };
573
+ const active = (record.launch_reservations ?? []).filter((item) => !item.released_at &&
574
+ item.subtask_id === reservation.subtask_id &&
575
+ item.attempt === reservation.attempt);
576
+ if (active.length) {
577
+ return {
578
+ ok: false,
579
+ reason: 'launch_already_reserved',
580
+ reservation: active[0],
581
+ };
582
+ }
583
+ record.launch_reservations = record.launch_reservations ?? [];
584
+ const entry = {
585
+ ...reservation,
586
+ reserved_at: nowIso(clock),
587
+ released_at: null,
588
+ outcome: null,
589
+ };
590
+ record.launch_reservations.push(entry);
591
+ return {
592
+ record,
593
+ op: 'launch-reserve',
594
+ summary: {
595
+ subtask_id: reservation.subtask_id,
596
+ attempt: reservation.attempt,
597
+ kind: reservation.kind,
598
+ },
599
+ result: { reservation_id: entry.reservation_id },
600
+ };
601
+ },
602
+ });
603
+ return outcome.ok
604
+ ? {
605
+ ok: true,
606
+ reservation_id: outcome.result.reservation_id,
607
+ state_revision: outcome.state_revision,
608
+ }
609
+ : outcome;
610
+ }
611
+ /**
612
+ * Settle a launch reservation and persist what actually happened.
613
+ *
614
+ * Outcomes are deliberately distinguished:
615
+ * exited / exited_nonzero the process ran; the attempt keeps a recorded native run, so a
616
+ * later `first` launch is refused with `already_launched`
617
+ * timed_out / spawn_failed the process may be alive or may have started; the attempt moves to
618
+ * `unknown` with an uncertainty, so nothing may be dispatched over it
619
+ * until it is reconciled
620
+ * not_started nothing was spawned (a validation failure); the reservation is
621
+ * released and the attempt is left exactly as it was
622
+ */
623
+ export function classifyIdentity({ ran, observed = null, expected = null, capabilityEvidence = null, }) {
624
+ const status = !ran
625
+ ? 'not_observed'
626
+ : !observed
627
+ ? 'unobserved'
628
+ : !expected
629
+ ? 'discovered'
630
+ : observed === expected
631
+ ? 'match'
632
+ : 'drift';
633
+ const confirmed = ran === true &&
634
+ status === 'match' &&
635
+ capabilityEvidence === 'runtime-bound';
636
+ return { status, confirmed };
637
+ }
638
+ export function settleLaunch(taskRoot, { reservationId, outcome, exitCode = null, sessionId = null, runId = null, evidenceRef = null, runner = null, detail = null, expectedIdentity = null, capabilityEvidence = null, identityStatus = null,
639
+ // A run is only "confirmed" when the process ended cleanly. `exited_nonzero` still records the
640
+ // attempted/started run and its honest exit, but the observation stays unconfirmed.
641
+ cleanExit = outcome === 'exited', clock = Date.now(), }) {
642
+ const notStarted = outcome === 'not_started';
643
+ const ran = outcome === 'exited' || outcome === 'exited_nonzero';
644
+ // Identity is decided before "did it run": a resumed session that comes back with a different
645
+ // id is a drift, and an identity that was never bound to the capability evidence cannot be
646
+ // promoted to `confirmed` no matter how cleanly the process exited.
647
+ const classified = classifyIdentity({
648
+ ran,
649
+ observed: sessionId,
650
+ expected: expectedIdentity,
651
+ capabilityEvidence,
652
+ });
653
+ const identityStatusResolved = identityStatus ?? classified.status;
654
+ const confirmed = ran &&
655
+ cleanExit === true &&
656
+ identityStatusResolved === 'match' &&
657
+ capabilityEvidence === 'runtime-bound';
658
+ const uncertain = outcome === 'timed_out' ||
659
+ outcome === 'spawn_failed' ||
660
+ outcome === 'evidence_write_failed' ||
661
+ identityStatusResolved === 'drift' ||
662
+ identityStatusResolved === 'unobserved' ||
663
+ (ran && !confirmed);
664
+ const settled = transact(taskRoot, {
665
+ actor: 'launcher',
666
+ purpose: 'launch-settle',
667
+ clock,
668
+ apply: (record) => {
669
+ const entry = (record.launch_reservations ?? []).find((item) => item.reservation_id === reservationId);
670
+ if (!entry)
671
+ return { ok: false, reason: 'unknown_reservation' };
672
+ if (entry.released_at)
673
+ return { ok: false, reason: 'reservation_already_settled' };
674
+ entry.released_at = nowIso(clock);
675
+ entry.outcome = outcome;
676
+ entry.exit_code = exitCode;
677
+ entry.session_id = sessionId;
678
+ entry.run_id = runId;
679
+ entry.detail = detail ?? null;
680
+ entry.identity_status = identityStatusResolved;
681
+ entry.expected_identity = expectedIdentity;
682
+ entry.observed_identity = sessionId;
683
+ if (notStarted)
684
+ return {
685
+ record,
686
+ op: 'launch-settle',
687
+ summary: { reservation_id: reservationId, outcome },
688
+ result: { outcome, attempt_unchanged: true },
689
+ };
690
+ const attempt = findAttempt(record, entry.subtask_id, entry.attempt);
691
+ if (!attempt)
692
+ return {
693
+ record,
694
+ op: 'launch-settle',
695
+ summary: { reservation_id: reservationId, outcome },
696
+ result: { outcome, attempt_missing: true },
697
+ };
698
+ const at = nowIso(clock);
699
+ // The native runner owns execution; the shared layer keeps the pointer and the fact
700
+ // that this attempt has been started at all.
701
+ attempt.native = {
702
+ runner: runner ?? attempt.native?.runner ?? null,
703
+ session_id: sessionId ?? attempt.native?.session_id ?? null,
704
+ run_id: runId ?? attempt.native?.run_id ?? null,
705
+ mission_id: attempt.native?.mission_id ?? null,
706
+ observation: confirmed ? 'confirmed' : 'unknown',
707
+ observed_at: at,
708
+ evidence_ref: evidenceRef ?? attempt.native?.evidence_ref ?? null,
709
+ manager: 'native',
710
+ launch_kind: entry.kind,
711
+ mode: entry.mode,
712
+ identity_status: identityStatusResolved,
713
+ };
714
+ attempt.updated_at = at;
715
+ attempt.transitions = attempt.transitions ?? [];
716
+ attempt.transitions.push({
717
+ from: attempt.state,
718
+ to: attempt.state,
719
+ at,
720
+ actor: 'launcher',
721
+ reason: `native launch settled: ${outcome}`,
722
+ evidence: evidenceRef,
723
+ });
724
+ if (ran && ['assigned', 'accepted'].includes(attempt.state))
725
+ attempt.state = 'running';
726
+ if (ran) {
727
+ // The process demonstrably started, so that question is answered even when the identity
728
+ // or the capability evidence is still unresolved.
729
+ const started = (record.uncertainty ?? []).find((item) => !item.resolved_at &&
730
+ item.kind === 'registered_not_started' &&
731
+ item.subtask_id === entry.subtask_id &&
732
+ item.attempt === entry.attempt);
733
+ if (started) {
734
+ started.resolved_at = at;
735
+ started.resolution = 'native launch observed';
736
+ started.resolution_evidence = evidenceRef;
737
+ started.resolved_by = 'launcher';
738
+ }
739
+ }
740
+ if (uncertain) {
741
+ // "May have started", "the identity drifted" and "it ran but nothing was recorded" are
742
+ // all reconcile-first states, never a free attempt.
743
+ if (!['completed', 'failed', 'cancelled'].includes(attempt.state))
744
+ attempt.state = 'unknown';
745
+ const kind = identityStatusResolved === 'drift'
746
+ ? 'session_identity_drift'
747
+ : identityStatusResolved === 'unobserved'
748
+ ? 'session_unobserved'
749
+ : ran && !confirmed
750
+ ? 'session_unverified'
751
+ : outcome === 'timed_out'
752
+ ? 'executed_unwritten'
753
+ : outcome === 'evidence_write_failed'
754
+ ? 'executed_unwritten'
755
+ : 'started_unregistered';
756
+ const detailText = identityStatusResolved === 'unobserved'
757
+ ? `launch ${entry.reservation_id} produced no readable native session identity; the requested identity ${entry.requested_session_id} is not evidence`
758
+ : identityStatusResolved === 'drift'
759
+ ? `launch ${entry.reservation_id} came back as ${sessionId}, but ${expectedIdentity} was expected: the contract does not describe this instance`
760
+ : ran && !confirmed
761
+ ? `launch ${entry.reservation_id} ran as native session ${sessionId}, but no runtime capability evidence covers that identity yet`
762
+ : outcome === 'evidence_write_failed'
763
+ ? `launch ${entry.reservation_id} ran but its evidence could not be written (${detail}); the execution is not on record`
764
+ : `launch ${entry.reservation_id} ended as ${outcome}; the native process state is unconfirmed`;
765
+ openUncertainty(record, {
766
+ kind,
767
+ subtaskId: entry.subtask_id,
768
+ attempt: entry.attempt,
769
+ detail: detailText,
770
+ evidenceRef,
771
+ resolutionHint: identityStatusResolved === 'unobserved'
772
+ ? 'inspect the native evidence: the session file or stream must expose the identity before it can be trusted'
773
+ : identityStatusResolved === 'drift'
774
+ ? 'query the native runner, then register a contract that names the instance that actually ran'
775
+ : ran && !confirmed
776
+ ? 'derive a capability card for this native session, then continue it with --launch-kind resume'
777
+ : 'query the native runner for this session/run and reconcile before any new dispatch',
778
+ clock,
779
+ });
780
+ }
781
+ return {
782
+ record,
783
+ op: 'launch-settle',
784
+ summary: { reservation_id: reservationId, outcome },
785
+ result: {
786
+ outcome,
787
+ state: attempt.state,
788
+ observation: attempt.native.observation,
789
+ identity_status: identityStatusResolved,
790
+ },
791
+ };
792
+ },
793
+ });
794
+ return settled.ok
795
+ ? { ok: true, outcome, result: settled.result }
796
+ : settled;
797
+ }