wowbagger 0.1.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 (46) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +201 -0
  3. package/README.md +464 -0
  4. package/adapters/claude-code/entrypoint.js +19 -0
  5. package/adapters/claude-code/wowbagger-adapter.json +25 -0
  6. package/adapters/codex/entrypoint.js +11 -0
  7. package/adapters/codex/wowbagger-adapter.json +25 -0
  8. package/adapters/opencode/entrypoint.js +11 -0
  9. package/adapters/opencode/wowbagger-adapter.json +25 -0
  10. package/bin/wowbagger.js +7 -0
  11. package/package.json +51 -0
  12. package/skills/wowbagger/SKILL.md +136 -0
  13. package/src/adapter/approval.js +135 -0
  14. package/src/adapter/bootstrap.js +43 -0
  15. package/src/adapter/context.js +34 -0
  16. package/src/adapter/core-probe.js +231 -0
  17. package/src/adapter/describe.js +383 -0
  18. package/src/adapter/entrypoint-main.js +335 -0
  19. package/src/adapter/entrypoint-path.js +103 -0
  20. package/src/adapter/handoff.js +124 -0
  21. package/src/adapter/instructions.js +106 -0
  22. package/src/adapter/invoke.js +294 -0
  23. package/src/adapter/limits.js +26 -0
  24. package/src/adapter/manifest.js +93 -0
  25. package/src/adapter/messages.js +15 -0
  26. package/src/adapter/paths.js +88 -0
  27. package/src/adapter/process-outcome.js +1116 -0
  28. package/src/adapter/schema-helpers.js +60 -0
  29. package/src/claim-capabilities.js +54 -0
  30. package/src/claim-coordinator.js +85 -0
  31. package/src/claim-journal.js +236 -0
  32. package/src/claim-operations.js +138 -0
  33. package/src/claim-publication.js +739 -0
  34. package/src/claim-request.js +140 -0
  35. package/src/claim-store.js +198 -0
  36. package/src/cli.js +1130 -0
  37. package/src/dependencies.js +3 -0
  38. package/src/git-reconciliation.js +62 -0
  39. package/src/ledger.js +296 -0
  40. package/src/mint.js +32 -0
  41. package/src/mutation.js +1979 -0
  42. package/src/namespace.js +35 -0
  43. package/src/ready.js +85 -0
  44. package/src/request.js +246 -0
  45. package/src/schema-migration.js +300 -0
  46. package/src/validate.js +1208 -0
@@ -0,0 +1,1116 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { parseLedgerItemSource } from '../ledger.js';
4
+ import {
5
+ createCandidateSource,
6
+ validateCreateRequest,
7
+ validateTransitionRequest,
8
+ } from '../mutation.js';
9
+ import { normalizeJsonValue, parseJsonRequest } from '../request.js';
10
+ import { validateLedger } from '../validate.js';
11
+ import { CORE_COMMAND_ORDER, CORE_CONTRACT_VERSION, verifyCoreProbe } from './core-probe.js';
12
+ import { isSafeLogicalPath } from './paths.js';
13
+ import { hasExactMembers, isNonNegativeSafeInteger, sameJson } from './schema-helpers.js';
14
+
15
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
16
+ const WOWBAGGER_ID = /^wb_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
17
+ const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/;
18
+ const MESSAGES = Object.freeze({
19
+ 'mutation-outcome-unknown': 'The mutation may have been applied; inspect current state before retrying.',
20
+ 'output-limit-exceeded': 'The core output exceeded the requested bound.',
21
+ });
22
+
23
+ const CORE_ERROR_EXIT_CODES = new Map([
24
+ ['invalid-request', 2],
25
+ ['item-not-found', 2],
26
+ ['transition-precondition-failed', 2],
27
+ ['patch-precondition-failed', 2],
28
+ ['candidate-invalid', 2],
29
+ ['ledger-invalid', 3],
30
+ ['revision-conflict', 4],
31
+ ['lock-held', 4],
32
+ ['id-collision', 4],
33
+ ['path-collision', 4],
34
+ ['atomic-scope-required', 5],
35
+ ['capability-unavailable', 5],
36
+ ['operation-failed', 6],
37
+ ['post-commit-recovery-required', 6],
38
+ ['write-outcome-unknown', 6],
39
+ ]);
40
+ const MUTATION_ERROR_STATES = new Map([
41
+ ['post-commit-recovery-required', 'committed'],
42
+ ['write-outcome-unknown', 'unknown'],
43
+ ]);
44
+ const CORE_ERROR_CODES_BY_COMMAND = Object.freeze({
45
+ inspect: new Set(['invalid-request', 'item-not-found', 'ledger-invalid']),
46
+ create: new Set([
47
+ 'invalid-request', 'ledger-invalid', 'lock-held', 'id-collision', 'path-collision',
48
+ 'candidate-invalid', 'capability-unavailable', 'operation-failed',
49
+ 'post-commit-recovery-required', 'write-outcome-unknown',
50
+ ]),
51
+ transition: new Set([
52
+ 'invalid-request', 'item-not-found', 'ledger-invalid', 'lock-held',
53
+ 'revision-conflict', 'atomic-scope-required', 'transition-precondition-failed',
54
+ 'candidate-invalid', 'operation-failed', 'post-commit-recovery-required',
55
+ 'write-outcome-unknown',
56
+ ]),
57
+ patch: new Set([
58
+ 'invalid-request', 'item-not-found', 'ledger-invalid', 'lock-held',
59
+ 'revision-conflict', 'patch-precondition-failed', 'candidate-invalid',
60
+ 'operation-failed', 'post-commit-recovery-required', 'write-outcome-unknown',
61
+ ]),
62
+ });
63
+ const INVALID_REQUEST_CODES = new Set([
64
+ 'invalid-json', 'duplicate-key', 'missing-member', 'unknown-member', 'invalid-type',
65
+ 'invalid-value', 'missing-argument', 'repeated-argument', 'unknown-argument',
66
+ ]);
67
+ const TRANSITION_ISSUE_MESSAGES = Object.freeze({
68
+ 'date-before-created': 'Transition date must not be earlier than the current created date.',
69
+ 'date-before-updated': 'Transition date must not be earlier than the current updated date.',
70
+ 'invalid-edge': 'The requested lifecycle edge is not allowed for this item.',
71
+ 'live-dependencies': 'Completion requires an empty depends_on list.',
72
+ 'nonterminal-children': 'Epic completion requires every direct child to be done or killed.',
73
+ });
74
+ const TRANSITION_ISSUE_FIELDS = Object.freeze({
75
+ 'date-before-created': 'date',
76
+ 'date-before-updated': 'date',
77
+ 'invalid-edge': 'to_status',
78
+ 'live-dependencies': 'depends_on',
79
+ 'nonterminal-children': 'parent',
80
+ });
81
+ const PATCH_ISSUE_MESSAGES = Object.freeze({
82
+ 'date-before-created': 'Patch date must not be earlier than the current created date.',
83
+ 'date-before-updated': 'Patch date must not be earlier than the current updated date.',
84
+ });
85
+ const TRANSITION_BLOCKER_FIELDS = Object.freeze({
86
+ 'dependent-cleanup': 'depends_on',
87
+ 'dependent-disposition': 'depends_on',
88
+ 'child-disposition': 'parent',
89
+ });
90
+
91
+ function error(code, details) {
92
+ return { code, message: MESSAGES[code] ?? `The adapter refused the operation (${code}).`, details };
93
+ }
94
+
95
+ function plainObject(value) {
96
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
97
+ }
98
+
99
+ function decodeCanonicalBase64(value) {
100
+ if (typeof value !== 'string' || value.includes('\n') || value.includes('\r')) return null;
101
+ const bytes = Buffer.from(value, 'base64');
102
+ return bytes.toString('base64') === value ? bytes : null;
103
+ }
104
+
105
+ function decodeUtf8(bytes) {
106
+ try {
107
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function isCalendarDate(value) {
114
+ if (typeof value !== 'string') return false;
115
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
116
+ if (!match) return false;
117
+ const year = Number(match[1]);
118
+ const month = Number(match[2]);
119
+ const day = Number(match[3]);
120
+ if (month < 1 || month > 12 || day < 1) return false;
121
+ const monthLengths = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28,
122
+ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
123
+ return day <= monthLengths[month - 1];
124
+ }
125
+
126
+ function isCoreRfc3339Utc(value) {
127
+ if (typeof value !== 'string') return false;
128
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(value);
129
+ return match !== null && isCalendarDate(match[1])
130
+ && Number(match[2]) <= 23 && Number(match[3]) <= 59 && Number(match[4]) <= 59;
131
+ }
132
+
133
+ function nonEmptyTrimmedString(value) {
134
+ return typeof value === 'string' && value.trim().length > 0 && !CONTROL_CHARACTER.test(value);
135
+ }
136
+
137
+ function nonEmptyControlFreeString(value) {
138
+ return typeof value === 'string' && value.length > 0 && !CONTROL_CHARACTER.test(value);
139
+ }
140
+
141
+ function isSafeLedgerDisplayPath(value) {
142
+ return isSafeLogicalPath(value) && value !== '.' && value.endsWith('.md');
143
+ }
144
+
145
+ function isSafeArtifactPath(value) {
146
+ return isSafeLogicalPath(value) && value !== '.' && [...value].length <= 1024;
147
+ }
148
+
149
+ function compareText(left, right) {
150
+ const leftPoints = [...left];
151
+ const rightPoints = [...right];
152
+ for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index += 1) {
153
+ const difference = leftPoints[index].codePointAt(0) - rightPoints[index].codePointAt(0);
154
+ if (difference !== 0) return difference;
155
+ }
156
+ return leftPoints.length - rightPoints.length;
157
+ }
158
+
159
+ function isOrdered(values, compare) {
160
+ return values.every((value, index) => index === 0 || compare(values[index - 1], value) <= 0);
161
+ }
162
+
163
+ function isJsonPointer(value) {
164
+ if (typeof value !== 'string' || CONTROL_CHARACTER.test(value)) return false;
165
+ return value === '' || /^\/(?:[^~]|~[01])*(?:\/(?:[^~]|~[01])*)*$/.test(value);
166
+ }
167
+
168
+ function isMutationCommand(command) {
169
+ return command === 'create' || command === 'transition' || command === 'patch';
170
+ }
171
+
172
+ function observationIssue(process) {
173
+ if (!hasExactMembers(process, [
174
+ 'started', 'process_tree_contained', 'orphaned', 'exit_code', 'signal', 'timed_out',
175
+ 'stdout_complete', 'stderr_complete', 'stdout_base64', 'stderr_base64',
176
+ ])) return 'members';
177
+ for (const member of [
178
+ 'started', 'process_tree_contained', 'orphaned', 'timed_out', 'stdout_complete', 'stderr_complete',
179
+ ]) {
180
+ if (typeof process[member] !== 'boolean') return member;
181
+ }
182
+ if (process.signal !== null && (typeof process.signal !== 'string' || process.signal.length === 0)) return 'signal';
183
+ if (process.exit_code !== null && !isNonNegativeSafeInteger(process.exit_code)) return 'exit_code';
184
+ const stdout = decodeCanonicalBase64(process.stdout_base64);
185
+ const stderr = decodeCanonicalBase64(process.stderr_base64);
186
+ if (stdout === null) return 'stdout_base64';
187
+ if (stderr === null) return 'stderr_base64';
188
+ if (!process.started) {
189
+ if (process.exit_code !== null || process.signal !== null || process.timed_out
190
+ || !process.process_tree_contained || process.orphaned
191
+ || !process.stdout_complete || !process.stderr_complete
192
+ || stdout.length > 0 || stderr.length > 0) return 'not-started-state';
193
+ return null;
194
+ }
195
+ if ((process.timed_out || process.signal !== null) && process.exit_code !== null) {
196
+ return 'contradictory-exit-state';
197
+ }
198
+ const incomplete = process.timed_out || process.signal !== null
199
+ || !process.process_tree_contained || process.orphaned
200
+ || !process.stdout_complete || !process.stderr_complete;
201
+ return process.exit_code === null && !incomplete ? 'exit_code' : null;
202
+ }
203
+
204
+ function hasRequiredFinalLf(bytes) {
205
+ if (bytes.length <= 1 || bytes[bytes.length - 1] !== 0x0A
206
+ || bytes[bytes.length - 2] === 0x0A || bytes[bytes.length - 2] === 0x0D) return false;
207
+ let inString = false;
208
+ let escaped = false;
209
+ for (let index = 0; index < bytes.length - 1; index += 1) {
210
+ const byte = bytes[index];
211
+ if (inString) {
212
+ if (escaped) escaped = false;
213
+ else if (byte === 0x5C) escaped = true;
214
+ else if (byte === 0x22) inString = false;
215
+ continue;
216
+ }
217
+ if (byte === 0x22) inString = true;
218
+ else if (byte === 0x09 || byte === 0x0A || byte === 0x0D || byte === 0x20) return false;
219
+ }
220
+ return !inString && !escaped;
221
+ }
222
+
223
+ function validValidationErrors(value) {
224
+ return Array.isArray(value) && value.every((error) => hasExactMembers(error, [
225
+ 'path', 'field', 'code', 'message',
226
+ ])
227
+ && nonEmptyControlFreeString(error.path)
228
+ && nonEmptyControlFreeString(error.field)
229
+ && nonEmptyControlFreeString(error.code)
230
+ && nonEmptyControlFreeString(error.message))
231
+ && isOrdered(value, compareValidationErrors);
232
+ }
233
+
234
+ function compareValidationErrors(left, right) {
235
+ return compareText(left.path, right.path)
236
+ || compareText(left.field, right.field)
237
+ || compareText(left.code, right.code)
238
+ || compareText(left.message, right.message);
239
+ }
240
+
241
+ function validCoreValidateEnvelope(value, exitCode) {
242
+ return hasExactMembers(value, ['valid', 'errors'])
243
+ && typeof value.valid === 'boolean'
244
+ && validValidationErrors(value.errors)
245
+ && exitCode === (value.valid ? 0 : 1)
246
+ && (value.valid ? value.errors.length === 0 : value.errors.length > 0);
247
+ }
248
+
249
+ function validCoreReadyEnvelope(value, exitCode, responseContext) {
250
+ const coreRequest = responseCoreRequest(responseContext, 'ready');
251
+ if (coreRequest === null) return false;
252
+ if (value?.valid === true) {
253
+ return hasExactMembers(value, ['as_of', 'valid', 'ready'])
254
+ && isCalendarDate(value.as_of)
255
+ && (coreRequest === undefined || value.as_of === coreRequest.as_of)
256
+ && validCoreRelationList(value.ready)
257
+ && exitCode === 0;
258
+ }
259
+ return value?.valid === false
260
+ && hasExactMembers(value, ['valid', 'errors'])
261
+ && validValidationErrors(value.errors) && value.errors.length > 0 && exitCode === 1;
262
+ }
263
+
264
+ function validCoreCapabilitiesEnvelope(value, exitCode) {
265
+ return verifyCoreProbe({
266
+ core: {
267
+ required_core_contract_version: CORE_CONTRACT_VERSION,
268
+ commands: [...CORE_COMMAND_ORDER],
269
+ },
270
+ optional_features: {
271
+ claims: value?.result?.operations?.work_claim?.supported === true,
272
+ policy: false,
273
+ },
274
+ }, value).ok && exitCode === 0;
275
+ }
276
+
277
+ function validCoreReadEnvelope(value, command, exitCode, responseContext) {
278
+ const coreRequest = responseCoreRequest(responseContext, command);
279
+ if (coreRequest === null) return false;
280
+ if (!plainObject(value)
281
+ || value.command !== command || value.contract_version !== CORE_CONTRACT_VERSION) return false;
282
+ if (value.ok === true) {
283
+ return hasExactMembers(value, ['ok', 'command', 'contract_version', 'result'])
284
+ && hasExactMembers(value.result, ['item'])
285
+ && validCoreItemShape(value.result.item)
286
+ && (coreRequest === undefined || value.result.item.id === coreRequest.id)
287
+ && exitCode === 0;
288
+ }
289
+ return value.ok === false
290
+ && hasExactMembers(value, ['ok', 'command', 'contract_version', 'error'])
291
+ && validCoreErrorAtExit(value.error, command, exitCode, responseContext);
292
+ }
293
+
294
+ function validCoreItemShape(value) {
295
+ if (!hasExactMembers(value, [
296
+ 'id', 'path', 'revision', 'source_encoding', 'source_media_type',
297
+ 'source_base64', 'core', 'body',
298
+ ])) return false;
299
+ if (!hasExactMembers(value.core, [
300
+ 'schema_version', 'id', 'title', 'kind', 'status', 'created', 'updated',
301
+ 'provenance', 'depends_on', 'related',
302
+ ], [
303
+ 'parent', 'snoozed_until', 'completed', 'killed', 'archived', 'number',
304
+ 'priority', 'decisions',
305
+ ])) return false;
306
+ if (!WOWBAGGER_ID.test(value.id)
307
+ || !isSafeLedgerDisplayPath(value.path)
308
+ || !DIGEST.test(value.revision)
309
+ || value.source_encoding !== 'base64'
310
+ || value.source_media_type !== 'text/markdown; charset=utf-8'
311
+ || typeof value.body !== 'string'
312
+ || !validCoreView(value.core)) return false;
313
+ const source = decodeCanonicalBase64(value.source_base64);
314
+ if (source === null || sha256(source) !== value.revision) return false;
315
+ const sourceText = decodeUtf8(source);
316
+ if (sourceText === null) return false;
317
+ const parsed = parseLedgerItemSource(sourceText);
318
+ const sourceCore = parsed.error ? null : sourceCoreView(parsed.data);
319
+ return sourceCore !== null
320
+ && value.id === value.core.id
321
+ && value.body === parsed.body
322
+ && sameJson(value.core, sourceCore)
323
+ && validReturnedItemSemantics(value.path, parsed.data);
324
+ }
325
+
326
+ function validCoreView(value) {
327
+ if (!plainObject(value)
328
+ || (value.schema_version !== 1 && value.schema_version !== 2)
329
+ || !WOWBAGGER_ID.test(value.id)
330
+ || !nonEmptyTrimmedString(value.title)
331
+ || !new Set(['task', 'epic']).has(value.kind)
332
+ || !new Set(['triage', 'backlog', 'in-progress', 'done', 'killed', 'archived']).has(value.status)
333
+ || !isCalendarDate(value.created)
334
+ || !isCalendarDate(value.updated)
335
+ || value.updated < value.created
336
+ || !hasExactMembers(value.provenance, ['source', 'recorded_at'])
337
+ || !nonEmptyTrimmedString(value.provenance.source)
338
+ || !isCoreRfc3339Utc(value.provenance.recorded_at)
339
+ || !validCoreRelationList(value.depends_on)
340
+ || !validCoreRelationList(value.related)) return false;
341
+
342
+ for (const field of ['parent']) {
343
+ if (Object.hasOwn(value, field) && !WOWBAGGER_ID.test(value[field])) return false;
344
+ }
345
+ for (const field of ['snoozed_until', 'completed', 'killed', 'archived']) {
346
+ if (Object.hasOwn(value, field) && !isCalendarDate(value[field])) return false;
347
+ }
348
+ if (Object.hasOwn(value, 'number')
349
+ && (!Number.isSafeInteger(value.number) || value.number < 1)) return false;
350
+ if (Object.hasOwn(value, 'priority')
351
+ && (!Number.isSafeInteger(value.priority) || value.priority < 0)) return false;
352
+ if (!validCoreTerminalDates(value)) return false;
353
+ return !Object.hasOwn(value, 'decisions') || validCoreDecisions(value.decisions);
354
+ }
355
+
356
+ function validCoreRelationList(value) {
357
+ return Array.isArray(value)
358
+ && value.every((id) => WOWBAGGER_ID.test(id))
359
+ && new Set(value).size === value.length;
360
+ }
361
+
362
+ function validCoreTerminalDates(value) {
363
+ const terminalFields = ['completed', 'killed', 'archived'];
364
+ const required = { done: 'completed', killed: 'killed', archived: 'archived' }[value.status] ?? null;
365
+ if (required === null) return terminalFields.every((field) => !Object.hasOwn(value, field));
366
+ return terminalFields.every((field) => field === required
367
+ ? Object.hasOwn(value, field) && value[field] === value.updated
368
+ : !Object.hasOwn(value, field));
369
+ }
370
+
371
+ function validCoreDecisions(value) {
372
+ if (!Array.isArray(value)) return false;
373
+ return value.every((decision) => {
374
+ if (!hasExactMembers(decision, ['action', 'date', 'summary', 'rationale'], ['rollup'])
375
+ || !new Set([
376
+ 'accept', 'complete', 'kill', 'archive', 'restore', 'replace-dependency',
377
+ 'waive-dependency', 'reparent', 'record',
378
+ ]).has(decision.action)
379
+ || !isCalendarDate(decision.date)
380
+ || !nonEmptyTrimmedString(decision.summary)
381
+ || !nonEmptyTrimmedString(decision.rationale)) return false;
382
+ if (!Object.hasOwn(decision, 'rollup')) return true;
383
+ return Array.isArray(decision.rollup)
384
+ && decision.rollup.every((entry) => hasExactMembers(entry, ['id', 'status'])
385
+ && WOWBAGGER_ID.test(entry.id)
386
+ && new Set(['done', 'killed']).has(entry.status));
387
+ });
388
+ }
389
+
390
+ function sourceCoreView(data) {
391
+ if (!plainObject(data)) return null;
392
+ const core = {};
393
+ for (const field of [
394
+ 'schema_version', 'id', 'title', 'kind', 'status', 'created', 'updated',
395
+ ]) {
396
+ if (!Object.hasOwn(data, field)) return null;
397
+ core[field] = data[field];
398
+ }
399
+ if (!plainObject(data.provenance)
400
+ || !Object.hasOwn(data.provenance, 'source')
401
+ || !Object.hasOwn(data.provenance, 'recorded_at')
402
+ || !Array.isArray(data.depends_on)
403
+ || (Object.hasOwn(data, 'related') && !Array.isArray(data.related))) return null;
404
+ core.provenance = {
405
+ source: data.provenance.source,
406
+ recorded_at: data.provenance.recorded_at,
407
+ };
408
+ core.depends_on = data.depends_on;
409
+ core.related = data.related ?? [];
410
+ for (const field of ['parent', 'snoozed_until', 'completed', 'killed', 'archived', 'number', 'priority']) {
411
+ if (Object.hasOwn(data, field)) core[field] = data[field];
412
+ }
413
+ if (Object.hasOwn(data, 'decisions')) {
414
+ if (!Array.isArray(data.decisions)) return null;
415
+ core.decisions = [];
416
+ for (const decision of data.decisions) {
417
+ if (!plainObject(decision)
418
+ || !['action', 'date', 'summary', 'rationale'].every((field) => Object.hasOwn(decision, field))) {
419
+ return null;
420
+ }
421
+ const normalized = {
422
+ action: decision.action,
423
+ date: decision.date,
424
+ summary: decision.summary,
425
+ rationale: decision.rationale,
426
+ };
427
+ if (Object.hasOwn(decision, 'rollup')) {
428
+ if (!Array.isArray(decision.rollup)) return null;
429
+ normalized.rollup = [];
430
+ for (const entry of decision.rollup) {
431
+ if (!plainObject(entry)) return null;
432
+ normalized.rollup.push({ id: entry.id, status: entry.status });
433
+ }
434
+ }
435
+ core.decisions.push(normalized);
436
+ }
437
+ }
438
+ return core;
439
+ }
440
+
441
+ function semanticSupportItems(data) {
442
+ const parentIds = new Set();
443
+ const referenceIds = new Set();
444
+ const rollupChildren = new Map();
445
+ if (Object.hasOwn(data, 'parent') && data.parent !== data.id) parentIds.add(data.parent);
446
+ for (const relation of ['depends_on', 'related']) {
447
+ for (const id of data[relation] ?? []) {
448
+ if (id !== data.id) referenceIds.add(id);
449
+ }
450
+ }
451
+ const rollup = matchingEpicRollup(data);
452
+ for (const entry of rollup) {
453
+ if (entry.id !== data.id && !rollupChildren.has(entry.id)) {
454
+ rollupChildren.set(entry.id, entry.status);
455
+ }
456
+ }
457
+
458
+ const ids = new Set([...parentIds, ...referenceIds, ...rollupChildren.keys()]);
459
+ return [...ids].sort().map((id) => {
460
+ if (parentIds.has(id)) return semanticSupportItem(id, { kind: 'epic' });
461
+ if (rollupChildren.has(id)) {
462
+ return semanticSupportItem(id, {
463
+ status: rollupChildren.get(id),
464
+ parent: data.id,
465
+ });
466
+ }
467
+ return semanticSupportItem(id);
468
+ });
469
+ }
470
+
471
+ function matchingEpicRollup(data) {
472
+ if (data.kind !== 'epic' || data.status !== 'done' || !Array.isArray(data.decisions)) return [];
473
+ const decision = data.decisions.find((candidate) => candidate?.action === 'complete'
474
+ && candidate.date === data.completed && Array.isArray(candidate.rollup));
475
+ return decision?.rollup ?? [];
476
+ }
477
+
478
+ function semanticSupportItem(id, { kind = 'task', status = 'backlog', parent = null } = {}) {
479
+ const date = dateFromWowbaggerId(id);
480
+ const terminal = {
481
+ done: ['completed', 'complete'],
482
+ killed: ['killed', 'kill'],
483
+ archived: ['archived', 'archive'],
484
+ }[status] ?? null;
485
+ const data = {
486
+ schema_version: 1,
487
+ id,
488
+ title: `Adapter semantic support ${id}`,
489
+ kind,
490
+ status,
491
+ created: date,
492
+ updated: date,
493
+ provenance: {
494
+ source: 'adapter-semantic-support',
495
+ recorded_at: `${date}T00:00:00Z`,
496
+ },
497
+ depends_on: [],
498
+ related: [],
499
+ ...(parent === null ? {} : { parent }),
500
+ };
501
+ if (terminal) {
502
+ const [field, action] = terminal;
503
+ data[field] = date;
504
+ data.decisions = [{
505
+ action,
506
+ date,
507
+ summary: `Support ${action} decision.`,
508
+ rationale: 'Synthetic relation support for adapter response validation.',
509
+ ...(kind === 'epic' && status === 'done' ? { rollup: [] } : {}),
510
+ }];
511
+ }
512
+ return { path: `.adapter-semantic-support/${id}.md`, data };
513
+ }
514
+
515
+ function dateFromWowbaggerId(id) {
516
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
517
+ let milliseconds = 0;
518
+ for (const character of id.slice(3, 13)) {
519
+ milliseconds = (milliseconds * 32) + alphabet.indexOf(character);
520
+ }
521
+ return new Date(milliseconds).toISOString().slice(0, 10);
522
+ }
523
+
524
+ function validReturnedItemSemantics(itemPath, data) {
525
+ const ledger = {
526
+ errors: [],
527
+ items: [
528
+ { path: itemPath, data },
529
+ ...semanticSupportItems(data),
530
+ ],
531
+ };
532
+ return !validateLedger(ledger).errors.some((internal) => internal.path === itemPath);
533
+ }
534
+
535
+ function validCoreMutationEnvelope(value, command, exitCode, responseContext) {
536
+ const mutationRequest = responseMutationRequest(responseContext, command);
537
+ const canonicalMutationRequest = hasCanonicalMutationRequest(responseContext, command);
538
+ if (!plainObject(value)
539
+ || value.command !== command || value.contract_version !== CORE_CONTRACT_VERSION) return false;
540
+ if (value.ok === true) {
541
+ return canonicalMutationRequest && value.state === 'committed'
542
+ && hasExactMembers(value, ['ok', 'command', 'contract_version', 'state', 'result'])
543
+ && hasExactMembers(value.result, ['item'])
544
+ && validCoreItemShape(value.result.item)
545
+ && validMutationResultCorrelation(value.result.item, command, mutationRequest)
546
+ && exitCode === 0;
547
+ }
548
+ return value.ok === false
549
+ && new Set(['unchanged', 'committed', 'unknown']).has(value.state)
550
+ && hasExactMembers(value, ['ok', 'command', 'contract_version', 'state', 'error'])
551
+ && validCoreErrorAtExit(value.error, command, exitCode, responseContext)
552
+ && value.state === (MUTATION_ERROR_STATES.get(value.error.code) ?? 'unchanged');
553
+ }
554
+
555
+ function responseCoreRequest(responseContext, command) {
556
+ if (responseContext === null || responseContext === undefined) return undefined;
557
+ const coreRequest = responseContext.core_request;
558
+ return plainObject(coreRequest) && coreRequest.command === command ? coreRequest : null;
559
+ }
560
+
561
+ function responseMutationInput(responseContext, command) {
562
+ const coreRequest = responseCoreRequest(responseContext, command);
563
+ if (coreRequest === undefined) return undefined;
564
+ if (coreRequest === null || !isMutationCommand(command)) return null;
565
+ if (!Object.hasOwn(responseContext, 'mutation_input')) return undefined;
566
+ return responseContext.mutation_input instanceof Uint8Array
567
+ ? responseContext.mutation_input
568
+ : null;
569
+ }
570
+
571
+ function responseMutationRequest(responseContext, command) {
572
+ const coreRequest = responseCoreRequest(responseContext, command);
573
+ if (coreRequest === undefined) return undefined;
574
+ if (coreRequest === null || !isMutationCommand(command)) return null;
575
+ const mutationInput = responseMutationInput(responseContext, command);
576
+ if (mutationInput !== undefined) {
577
+ if (mutationInput === null) return null;
578
+ const parsed = parseJsonRequest(mutationInput);
579
+ return plainObject(parsed.value) ? parsed.value : null;
580
+ }
581
+ return plainObject(responseContext.mutation_request) ? responseContext.mutation_request : null;
582
+ }
583
+
584
+ function responseItemId(responseContext, command) {
585
+ if (command === 'inspect') {
586
+ const coreRequest = responseCoreRequest(responseContext, command);
587
+ if (coreRequest === undefined) return undefined;
588
+ return WOWBAGGER_ID.test(coreRequest?.id) ? coreRequest.id : null;
589
+ }
590
+ const mutationRequest = responseMutationRequest(responseContext, command);
591
+ if (mutationRequest === undefined) return undefined;
592
+ return WOWBAGGER_ID.test(mutationRequest?.id) ? mutationRequest.id : null;
593
+ }
594
+
595
+ function responseExpectedRevision(responseContext, command) {
596
+ const mutationRequest = responseMutationRequest(responseContext, command);
597
+ if (mutationRequest === undefined) return undefined;
598
+ return (command === 'transition' || command === 'patch') && DIGEST.test(mutationRequest?.expected_revision)
599
+ ? mutationRequest.expected_revision
600
+ : null;
601
+ }
602
+
603
+ function expectedCreateCandidateRevision(responseContext, command) {
604
+ if (command !== 'create') return null;
605
+ const mutationRequest = responseMutationRequest(responseContext, command);
606
+ if (mutationRequest === undefined) return undefined;
607
+ if (!hasCanonicalMutationRequest(responseContext, command)) return null;
608
+ try {
609
+ return sha256(createCandidateSource(mutationRequest));
610
+ } catch {
611
+ return null;
612
+ }
613
+ }
614
+
615
+ function hasCanonicalMutationRequest(responseContext, command) {
616
+ const mutationInput = responseMutationInput(responseContext, command);
617
+ if (mutationInput !== undefined) {
618
+ if (mutationInput === null) return false;
619
+ const parsed = parseJsonRequest(mutationInput);
620
+ if (parsed.issues.length > 0 || !plainObject(parsed.value)) return false;
621
+ if (command === 'create') return validateCreateRequest(parsed.value).length === 0;
622
+ if (command === 'transition') return validateTransitionRequest(parsed.value).length === 0;
623
+ if (command === 'patch') return validPatchRequest(parsed.value);
624
+ return false;
625
+ }
626
+ const mutationRequest = responseMutationRequest(responseContext, command);
627
+ if (mutationRequest === undefined) return true;
628
+ if (!plainObject(mutationRequest)) return false;
629
+ if (command === 'create') return validateCreateRequest(mutationRequest).length === 0;
630
+ if (command === 'transition') return validateTransitionRequest(mutationRequest).length === 0;
631
+ if (command === 'patch') return validPatchRequest(mutationRequest);
632
+ return false;
633
+ }
634
+
635
+ function validMutationResultCorrelation(item, command, mutationRequest) {
636
+ if (mutationRequest === undefined) return true;
637
+ if (command === 'create') return validCreateResultCorrelation(item, mutationRequest);
638
+ if (command === 'patch') return validPatchResultCorrelation(item, mutationRequest);
639
+ return validTransitionResultCorrelation(item, mutationRequest);
640
+ }
641
+
642
+ function validCreateResultCorrelation(item, request) {
643
+ if (!plainObject(request) || !plainObject(request.item)
644
+ || item.id !== request.id || item.path !== `${request.id}.md` || item.body !== request.body
645
+ || item.core.schema_version !== 1 || item.core.status !== 'triage'
646
+ || item.core.title !== request.item.title || item.core.kind !== request.item.kind
647
+ || !sameJson(item.core.provenance, {
648
+ source: request.item.provenance?.source,
649
+ recorded_at: request.item.provenance?.recorded_at,
650
+ })
651
+ || !sameJson(item.core.depends_on, request.item.depends_on)
652
+ || !sameJson(item.core.related, request.item.related ?? [])) return false;
653
+ for (const field of ['parent', 'snoozed_until']) {
654
+ if (Object.hasOwn(item.core, field) !== Object.hasOwn(request.item, field)
655
+ || (Object.hasOwn(item.core, field) && item.core[field] !== request.item[field])) return false;
656
+ }
657
+ if (['completed', 'killed', 'archived', 'decisions'].some((field) => Object.hasOwn(item.core, field))) {
658
+ return false;
659
+ }
660
+ const source = decodeCanonicalBase64(item.source_base64);
661
+ try {
662
+ return source !== null && source.equals(createCandidateSource(request));
663
+ } catch {
664
+ return false;
665
+ }
666
+ }
667
+
668
+ function validTransitionResultCorrelation(item, request) {
669
+ return plainObject(request)
670
+ && item.id === request.id
671
+ && item.revision !== request.expected_revision
672
+ && item.core.status === request.to_status
673
+ && item.core.updated === request.date
674
+ && (!Object.hasOwn(request, 'decision')
675
+ || (plainObject(request.decision) && Array.isArray(item.core.decisions)
676
+ && item.core.decisions.some((decision) => {
677
+ const actions = request.to_status === 'backlog'
678
+ ? new Set(['accept', 'restore'])
679
+ : new Set(['complete', 'kill', 'archive']);
680
+ return actions.has(decision.action)
681
+ && decision.date === request.date
682
+ && decision.summary === request.decision.summary
683
+ && decision.rationale === request.decision.rationale;
684
+ })));
685
+ }
686
+
687
+ function validPatchRequest(request) {
688
+ if (!hasExactMembers(request, ['id', 'expected_revision', 'date', 'set'])
689
+ || !WOWBAGGER_ID.test(request.id)
690
+ || !DIGEST.test(request.expected_revision)
691
+ || !isCalendarDate(request.date)
692
+ || !hasExactMembers(request.set, [], ['number', 'priority'])
693
+ || Object.keys(request.set).length === 0) return false;
694
+ for (const [field, value] of Object.entries(request.set)) {
695
+ if (value === null) continue;
696
+ const minimum = field === 'number' ? 1 : 0;
697
+ if (parsedIntegerValue(value, minimum) === undefined) return false;
698
+ }
699
+ return true;
700
+ }
701
+
702
+ function parsedIntegerValue(value, minimum) {
703
+ if (Number.isSafeInteger(value) && value >= minimum) return value;
704
+ if (value === null || typeof value !== 'object'
705
+ || Object.getPrototypeOf(value)?.constructor?.name !== 'JsonNumber'
706
+ || !hasExactMembers(value, ['source'])
707
+ || typeof value.source !== 'string'
708
+ || !/^(0|[1-9][0-9]*)$/.test(value.source)) return undefined;
709
+ const parsed = Number(value.source);
710
+ return Number.isSafeInteger(parsed) && parsed >= minimum ? parsed : undefined;
711
+ }
712
+
713
+ function validPatchResultCorrelation(item, request) {
714
+ return validPatchRequest(request)
715
+ && item.id === request.id
716
+ && item.revision !== request.expected_revision
717
+ && item.core.updated === request.date
718
+ && Object.entries(request.set).every(([field, requested]) => {
719
+ if (requested === null) return !Object.hasOwn(item.core, field);
720
+ return item.core[field] === parsedIntegerValue(requested, field === 'number' ? 1 : 0);
721
+ });
722
+ }
723
+
724
+ function validCoreErrorAtExit(value, command, exitCode, responseContext) {
725
+ if (!hasExactMembers(value, ['code', 'message', 'details'])
726
+ || !CORE_ERROR_CODES_BY_COMMAND[command]?.has(value.code)
727
+ || CORE_ERROR_EXIT_CODES.get(value.code) !== exitCode
728
+ || !coreErrorMessageMatches(value.code, command, value.message)) return false;
729
+ return validCoreErrorDetails(value.code, value.details, command, responseContext);
730
+ }
731
+
732
+ function coreErrorMessageMatches(code, command, message) {
733
+ const expected = {
734
+ 'item-not-found': 'The requested item was not found.',
735
+ 'ledger-invalid': 'The configured ledger is invalid.',
736
+ 'transition-precondition-failed': 'The requested lifecycle transition failed its preconditions.',
737
+ 'patch-precondition-failed': 'The requested patch failed its preconditions.',
738
+ 'candidate-invalid': 'The proposed item would make the ledger invalid.',
739
+ 'revision-conflict': 'The item changed after it was inspected.',
740
+ 'lock-held': 'The item is locked by another cooperative Wowbagger writer.',
741
+ 'id-collision': 'The requested item ID already exists.',
742
+ 'path-collision': 'The default item path is occupied by a different item.',
743
+ 'atomic-scope-required': 'The requested transition requires multi-item atomicity.',
744
+ 'capability-unavailable': 'Atomic no-clobber publication is unavailable for this ledger.',
745
+ 'operation-failed': 'The mutation operation failed before a commit was established.',
746
+ 'post-commit-recovery-required': 'The item was committed, but cleanup requires recovery.',
747
+ 'write-outcome-unknown': `The ${command} publication outcome could not be verified.`,
748
+ }[code] ?? `The ${command} request is invalid.`;
749
+ return message === expected;
750
+ }
751
+
752
+ function validCoreErrorDetails(code, details, command, responseContext) {
753
+ const expectedItemId = responseItemId(responseContext, command);
754
+ const expectedRevision = responseExpectedRevision(responseContext, command);
755
+ const expectedCreateRevision = expectedCreateCandidateRevision(responseContext, command);
756
+ const matchesItemId = (id) => expectedItemId === undefined || id === expectedItemId;
757
+ if (code !== 'invalid-request' && !hasCanonicalMutationRequest(responseContext, command)) {
758
+ return false;
759
+ }
760
+ switch (code) {
761
+ case 'invalid-request': return !hasCanonicalMutationRequest(responseContext, command)
762
+ && validInvalidRequestDetails(details);
763
+ case 'item-not-found': return hasExactMembers(details, ['id'])
764
+ && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id);
765
+ case 'ledger-invalid': return hasExactMembers(details, ['validation_errors'])
766
+ && validValidationErrors(details.validation_errors) && details.validation_errors.length > 0;
767
+ case 'transition-precondition-failed': return hasExactMembers(details, ['id', 'issues'])
768
+ && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id)
769
+ && validTransitionIssues(details.issues) && details.issues.length > 0;
770
+ case 'patch-precondition-failed': return hasExactMembers(details, ['id', 'issues'])
771
+ && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id)
772
+ && validPatchIssues(details.issues) && details.issues.length > 0;
773
+ case 'candidate-invalid': return hasExactMembers(details, ['id', 'validation_errors'])
774
+ && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id)
775
+ && validValidationErrors(details.validation_errors) && details.validation_errors.length > 0;
776
+ case 'revision-conflict': return hasExactMembers(details, [
777
+ 'id', 'expected_revision', 'actual_revision',
778
+ ]) && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id)
779
+ && DIGEST.test(details.expected_revision) && DIGEST.test(details.actual_revision)
780
+ && details.actual_revision !== details.expected_revision
781
+ && (expectedRevision === undefined || details.expected_revision === expectedRevision);
782
+ case 'lock-held': return validLockHeldDetails(details) && matchesItemId(details.id);
783
+ case 'id-collision': return hasExactMembers(details, ['id', 'path', 'actual_revision'])
784
+ && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id)
785
+ && isSafeLedgerDisplayPath(details.path)
786
+ && DIGEST.test(details.actual_revision);
787
+ case 'path-collision': return validPathCollisionDetails(details) && matchesItemId(details.id)
788
+ && (command !== 'create' || expectedItemId === undefined
789
+ || details.path === `${expectedItemId}.md`);
790
+ case 'atomic-scope-required': return hasExactMembers(details, [
791
+ 'id', 'blockers', 'precondition_issues',
792
+ ]) && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id)
793
+ && validTransitionBlockers(details.blockers)
794
+ && validTransitionIssues(details.precondition_issues);
795
+ case 'capability-unavailable': return validCapabilityUnavailableDetails(details);
796
+ case 'operation-failed': return validOperationFailedDetails(details) && matchesItemId(details.id);
797
+ case 'post-commit-recovery-required': return hasExactMembers(details, [
798
+ 'id', 'revision', 'recovery_artifacts', 'recovery_artifacts_truncated',
799
+ ]) && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id) && DIGEST.test(details.revision)
800
+ && (command !== 'create' || expectedCreateRevision === undefined
801
+ || details.revision === expectedCreateRevision)
802
+ && ((command !== 'transition' && command !== 'patch')
803
+ || expectedRevision === undefined || details.revision !== expectedRevision)
804
+ && validRecoveryArtifacts(details.recovery_artifacts, details.recovery_artifacts_truncated);
805
+ case 'write-outcome-unknown': return hasExactMembers(details, [
806
+ 'id', 'recovery_artifacts', 'recovery_artifacts_truncated',
807
+ ]) && WOWBAGGER_ID.test(details.id) && matchesItemId(details.id)
808
+ && validRecoveryArtifacts(details.recovery_artifacts, details.recovery_artifacts_truncated);
809
+ default: return false;
810
+ }
811
+ }
812
+
813
+ function validInvalidRequestDetails(value) {
814
+ return hasExactMembers(value, ['issues']) && Array.isArray(value.issues) && value.issues.length > 0
815
+ && value.issues.every((issue) => hasExactMembers(issue, ['path', 'code', 'message'])
816
+ && isJsonPointer(issue.path)
817
+ && INVALID_REQUEST_CODES.has(issue.code)
818
+ && nonEmptyControlFreeString(issue.message))
819
+ && isOrdered(value.issues, compareInvalidRequestIssues);
820
+ }
821
+
822
+ function compareInvalidRequestIssues(left, right) {
823
+ return compareText(left.path, right.path)
824
+ || compareText(left.code, right.code)
825
+ || compareText(left.message, right.message);
826
+ }
827
+
828
+ function validTransitionIssues(value) {
829
+ if (!Array.isArray(value)) return false;
830
+ return value.every((issue) => hasExactMembers(issue, ['code', 'field', 'message', 'related_ids'])
831
+ && Object.hasOwn(TRANSITION_ISSUE_MESSAGES, issue.code)
832
+ && issue.field === TRANSITION_ISSUE_FIELDS[issue.code]
833
+ && issue.message === TRANSITION_ISSUE_MESSAGES[issue.code]
834
+ && validSortedUniqueIds(issue.related_ids))
835
+ && isOrdered(value, compareTransitionIssues);
836
+ }
837
+
838
+ function validPatchIssues(value) {
839
+ if (!Array.isArray(value)) return false;
840
+ return value.every((issue) => hasExactMembers(issue, ['code', 'field', 'message', 'related_ids'])
841
+ && Object.hasOwn(PATCH_ISSUE_MESSAGES, issue.code)
842
+ && issue.field === 'date'
843
+ && issue.message === PATCH_ISSUE_MESSAGES[issue.code]
844
+ && sameJson(issue.related_ids, []))
845
+ && isOrdered(value, compareTransitionIssues);
846
+ }
847
+
848
+ function compareTransitionIssues(left, right) {
849
+ return compareText(left.code, right.code)
850
+ || compareText(left.field, right.field)
851
+ || compareText(left.related_ids.join('\0'), right.related_ids.join('\0'));
852
+ }
853
+
854
+ function validTransitionBlockers(value) {
855
+ if (!Array.isArray(value) || value.length === 0) return false;
856
+ const seen = new Set();
857
+ for (const [index, blocker] of value.entries()) {
858
+ if (!hasExactMembers(blocker, ['code', 'item_id', 'field'])
859
+ || !Object.hasOwn(TRANSITION_BLOCKER_FIELDS, blocker.code)
860
+ || !WOWBAGGER_ID.test(blocker.item_id)
861
+ || blocker.field !== TRANSITION_BLOCKER_FIELDS[blocker.code]
862
+ || (index > 0 && compareTransitionBlockers(value[index - 1], blocker) > 0)) return false;
863
+ const key = `${blocker.code}\0${blocker.item_id}\0${blocker.field}`;
864
+ if (seen.has(key)) return false;
865
+ seen.add(key);
866
+ }
867
+ return true;
868
+ }
869
+
870
+ function compareTransitionBlockers(left, right) {
871
+ return compareText(left.code, right.code)
872
+ || compareText(left.item_id, right.item_id)
873
+ || compareText(left.field, right.field);
874
+ }
875
+
876
+ function validLockHeldDetails(value) {
877
+ if (!hasExactMembers(value, ['id', 'lock_path', 'owner', 'owner_diagnostic'])
878
+ || !WOWBAGGER_ID.test(value.id)
879
+ || value.lock_path !== `.wowbagger-locks/${value.id}.lock`) return false;
880
+ const diagnostics = new Set(['too-large', 'invalid-utf8', 'duplicate-key', 'invalid-json', 'invalid-shape']);
881
+ if (value.owner === null) return diagnostics.has(value.owner_diagnostic);
882
+ return value.owner_diagnostic === null
883
+ && hasExactMembers(value.owner, ['lock_version', 'item_id', 'operation', 'writer_id', 'started_at'])
884
+ && value.owner.lock_version === 1
885
+ && value.owner.item_id === value.id
886
+ && new Set(['create', 'transition', 'patch']).has(value.owner.operation)
887
+ && typeof value.owner.writer_id === 'string'
888
+ && /^[\x21-\x7e]{1,128}$/.test(value.owner.writer_id)
889
+ && isCoreRfc3339Utc(value.owner.started_at);
890
+ }
891
+
892
+ function validPathCollisionDetails(value) {
893
+ if (!hasExactMembers(value, ['id', 'path', 'occupant_kind'], ['occupying_id'])
894
+ || !WOWBAGGER_ID.test(value.id)
895
+ || !isSafeLedgerDisplayPath(value.path)
896
+ || !new Set(['item', 'directory']).has(value.occupant_kind)) return false;
897
+ return value.occupant_kind === 'item'
898
+ ? Object.hasOwn(value, 'occupying_id') && WOWBAGGER_ID.test(value.occupying_id)
899
+ : !Object.hasOwn(value, 'occupying_id');
900
+ }
901
+
902
+ function validCapabilityUnavailableDetails(value) {
903
+ return hasExactMembers(value, [
904
+ 'capability', 'reason', 'recovery_artifacts', 'recovery_artifacts_truncated',
905
+ ]) && value.capability === 'atomic-no-clobber-publication'
906
+ && value.reason === 'filesystem-primitive-unavailable'
907
+ && validRecoveryArtifacts(value.recovery_artifacts, value.recovery_artifacts_truncated);
908
+ }
909
+
910
+ function validOperationFailedDetails(value) {
911
+ if (!hasExactMembers(value, [
912
+ 'id', 'operation', 'reason', 'recovery_artifacts', 'recovery_artifacts_truncated',
913
+ ]) || !WOWBAGGER_ID.test(value.id)
914
+ || !new Set([
915
+ 'lock-closure', 'prepare-temporary', 'sync-temporary', 'publish',
916
+ 'verify-publication', 'cleanup',
917
+ ]).has(value.operation)
918
+ || !new Set(['retry-limit-exhausted', 'io-error', 'verification-failed']).has(value.reason)
919
+ || !validRecoveryArtifacts(value.recovery_artifacts, value.recovery_artifacts_truncated)) return false;
920
+ return (value.reason !== 'retry-limit-exhausted' || value.operation === 'lock-closure')
921
+ && (value.reason !== 'verification-failed'
922
+ || value.operation === 'publish' || value.operation === 'verify-publication');
923
+ }
924
+
925
+ function validRecoveryArtifacts(value, truncated) {
926
+ if (!Array.isArray(value) || typeof truncated !== 'boolean' || value.length > 16
927
+ || (truncated && value.length !== 16)) return false;
928
+ const seen = new Set();
929
+ for (const artifact of value) {
930
+ if (!hasExactMembers(artifact, ['path', 'kind', 'sha256', 'size_bytes'])
931
+ || !isSafeArtifactPath(artifact.path)
932
+ || !new Set(['temporary-file', 'lock-file', 'final-item']).has(artifact.kind)) return false;
933
+ const readable = DIGEST.test(artifact.sha256) && isNonNegativeSafeInteger(artifact.size_bytes);
934
+ if (!readable && !(artifact.sha256 === null && artifact.size_bytes === null)) return false;
935
+ const key = artifact.path;
936
+ if (seen.has(key)) return false;
937
+ seen.add(key);
938
+ }
939
+ return isOrdered(value, compareRecoveryArtifacts);
940
+ }
941
+
942
+ function compareRecoveryArtifacts(left, right) {
943
+ return compareText(left.path, right.path) || compareText(left.kind, right.kind);
944
+ }
945
+
946
+ function validSortedUniqueIds(value) {
947
+ return Array.isArray(value)
948
+ && value.every((id) => WOWBAGGER_ID.test(id))
949
+ && new Set(value).size === value.length
950
+ && value.every((id, index) => index === 0 || value[index - 1] < id);
951
+ }
952
+
953
+ function sha256(bytes) {
954
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
955
+ }
956
+
957
+ function envelopeState(process, command = null, responseContext = null) {
958
+ const bytes = decodeCanonicalBase64(process?.stdout_base64);
959
+ if (bytes === null) return { present: false, valid: false };
960
+ const parsed = parseJsonRequest(bytes);
961
+ const value = parsed.issues.length === 0
962
+ ? normalizeJsonValue(parsed.value)
963
+ : null;
964
+ const valid = hasRequiredFinalLf(bytes) && parsed.issues.length === 0
965
+ && validCoreCommandEnvelope(value, command, process?.exit_code, responseContext);
966
+ return {
967
+ present: bytes.length > 0,
968
+ valid,
969
+ mutation_state: valid && isMutationCommand(command)
970
+ ? value.state
971
+ : null,
972
+ };
973
+ }
974
+
975
+ function validCoreCommandEnvelope(value, command, exitCode, responseContext) {
976
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
977
+ if (command === 'validate') return validCoreValidateEnvelope(value, exitCode);
978
+ if (command === 'ready') return validCoreReadyEnvelope(value, exitCode, responseContext);
979
+ if (command === 'capabilities') return validCoreCapabilitiesEnvelope(value, exitCode);
980
+ if (command === 'inspect') return validCoreReadEnvelope(value, command, exitCode, responseContext);
981
+ if (isMutationCommand(command)) {
982
+ return validCoreMutationEnvelope(value, command, exitCode, responseContext);
983
+ }
984
+ return false;
985
+ }
986
+
987
+ function processSummary(process, command, responseContext) {
988
+ const envelope = envelopeState(process, command, responseContext);
989
+ return {
990
+ started: process?.started ?? null,
991
+ process_tree_contained: process?.process_tree_contained ?? null,
992
+ orphaned: process?.orphaned ?? null,
993
+ exit_code: process?.exit_code ?? null,
994
+ signal: process?.signal ?? null,
995
+ timed_out: process?.timed_out ?? null,
996
+ stdout_complete: process?.stdout_complete ?? null,
997
+ stderr_complete: process?.stderr_complete ?? null,
998
+ core_envelope_present: envelope.present,
999
+ core_envelope_valid: envelope.valid,
1000
+ };
1001
+ }
1002
+
1003
+ function capturedStreamsOverLimit(process, stdoutLimit, stderrLimit) {
1004
+ const streams = [];
1005
+ for (const [stream, limit] of [
1006
+ ['stdout', stdoutLimit],
1007
+ ['stderr', stderrLimit],
1008
+ ]) {
1009
+ if (limit === undefined) continue;
1010
+ if (!isNonNegativeSafeInteger(limit)) return ['stdout', 'stderr'];
1011
+ const bytes = decodeCanonicalBase64(process[`${stream}_base64`]);
1012
+ if (bytes !== null && bytes.length > limit) streams.push(stream);
1013
+ }
1014
+ return streams;
1015
+ }
1016
+
1017
+ function mutationUnknown(base, command, itemId, expectedRevision, process, processIssue, responseContext) {
1018
+ const recovery = command === 'create'
1019
+ ? {
1020
+ action: 'inspect-caller-known-id',
1021
+ validate_ledger_first: true,
1022
+ retry: 'only-after-item-not-found-and-audited-artifact-recovery',
1023
+ }
1024
+ : {
1025
+ action: 'validate-inspect-and-compare-revision',
1026
+ expected_revision: expectedRevision,
1027
+ retry: 'never-before-current-state-review',
1028
+ };
1029
+ const details = { command, item_id: itemId, recovery };
1030
+ if (processIssue) details.process_issue = processIssue;
1031
+ return {
1032
+ ...base,
1033
+ mutation_outcome: 'unknown',
1034
+ error: error('mutation-outcome-unknown', details),
1035
+ process: processSummary(process, command, responseContext),
1036
+ };
1037
+ }
1038
+
1039
+ export function mapProcessOutcome({
1040
+ adapter_contract_version: adapterContractVersion,
1041
+ request_id: requestId,
1042
+ command,
1043
+ core_request: coreRequest = null,
1044
+ mutation_request: mutationRequest = null,
1045
+ mutation_input: mutationInput,
1046
+ item_id: itemId,
1047
+ expected_revision: expectedRevision,
1048
+ stdout_limit_bytes: stdoutLimit,
1049
+ stderr_limit_bytes: stderrLimit,
1050
+ process,
1051
+ }) {
1052
+ const base = { ok: false, adapter_contract_version: adapterContractVersion, request_id: requestId };
1053
+ const mutation = isMutationCommand(command);
1054
+ const responseContext = coreRequest === null
1055
+ ? null
1056
+ : {
1057
+ core_request: coreRequest,
1058
+ mutation_request: mutationRequest,
1059
+ ...(mutationInput === undefined ? {} : { mutation_input: mutationInput }),
1060
+ };
1061
+ const issue = observationIssue(process);
1062
+ if (issue) {
1063
+ // A contradicted or unsigned observation on a mutation can never prove the
1064
+ // mutation did not apply, so it is always outcome-unknown. The launchState
1065
+ // 'not-started' is only reachable when issue is null (a clean non-start), so
1066
+ // every truthy issue on a mutation maps to unknown regardless of `started`.
1067
+ if (mutation) {
1068
+ return mutationUnknown(base, command, itemId, expectedRevision, process, issue, responseContext);
1069
+ }
1070
+ return {
1071
+ ...base,
1072
+ error: error('core-observation-incomplete', { reason: 'invalid-process-observation', member: issue }),
1073
+ process: processSummary(process, command, responseContext),
1074
+ };
1075
+ }
1076
+ if (!process.started) {
1077
+ return { ...base, error: error('core-launch-failed', {}), process: processSummary(process, command, responseContext) };
1078
+ }
1079
+ const envelope = envelopeState(process, command, responseContext);
1080
+ const overLimitStreams = capturedStreamsOverLimit(process, stdoutLimit, stderrLimit);
1081
+ const ambiguous = process.timed_out || process.signal !== null
1082
+ || !process.process_tree_contained || process.orphaned
1083
+ || !process.stdout_complete || !process.stderr_complete || overLimitStreams.length > 0
1084
+ || !envelope.present || !envelope.valid
1085
+ || envelope.mutation_state === 'unknown';
1086
+ if (mutation && ambiguous) {
1087
+ return mutationUnknown(base, command, itemId, expectedRevision, process, null, responseContext);
1088
+ }
1089
+ if (process.timed_out) {
1090
+ return { ...base, error: error('core-timeout', {}), process: processSummary(process, command, responseContext) };
1091
+ }
1092
+ if (process.signal !== null) {
1093
+ return { ...base, error: error('core-signaled', { signal: process.signal }), process: processSummary(process, command, responseContext) };
1094
+ }
1095
+ if (!process.process_tree_contained || process.orphaned) {
1096
+ return { ...base, error: error('core-observation-incomplete', {}), process: processSummary(process, command, responseContext) };
1097
+ }
1098
+ if (!process.stdout_complete || !process.stderr_complete || overLimitStreams.length > 0) {
1099
+ const streams = new Set(overLimitStreams);
1100
+ if (!process.stdout_complete) streams.add('stdout');
1101
+ if (!process.stderr_complete) streams.add('stderr');
1102
+ return {
1103
+ ...base,
1104
+ error: error('output-limit-exceeded', { streams: [...streams] }),
1105
+ process: processSummary(process, command, responseContext),
1106
+ };
1107
+ }
1108
+ if (!envelope.present || !envelope.valid) {
1109
+ return {
1110
+ ...base,
1111
+ error: error('core-protocol-error', { envelope_present: envelope.present, envelope_valid: envelope.valid }),
1112
+ process: processSummary(process, command, responseContext),
1113
+ };
1114
+ }
1115
+ return null;
1116
+ }