@litterthanlit/hypher-cli 0.1.0

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.
@@ -0,0 +1,796 @@
1
+ // Generated from hypher-web/shared/structuredHandoff.ts by script/sync-hypher-shared.mjs. Do not edit.
2
+ /**
3
+ * Versioned handoff proposal for an explicit agent switch.
4
+ * Hypher validates and stores the note. It does not transfer files.
5
+ */
6
+ import { findSecretInValue } from "./secretScan.mjs";
7
+ export const HANDOFF_PROPOSAL_SCHEMA_VERSION = 1;
8
+ const MAX_ITEMS = 8;
9
+ const MAX_SHORT = 280;
10
+ const MAX_GOAL = 600;
11
+ const MAX_KEY = 200;
12
+ const MAX_BRANCH = 200;
13
+ const MAX_COMMIT = 80;
14
+ const MAX_DESTINATION = 80;
15
+ const MAX_REASON = 500;
16
+ const PROPOSAL_KEYS = new Set([
17
+ "schemaVersion",
18
+ "goal",
19
+ "constraints",
20
+ "decisions",
21
+ "completed",
22
+ "unverified",
23
+ "blockers",
24
+ "nextAction",
25
+ "sources",
26
+ "repo",
27
+ ]);
28
+ export const HANDOFF_PROPOSAL_JSON_SCHEMA = {
29
+ type: "object",
30
+ additionalProperties: false,
31
+ required: [
32
+ "schemaVersion",
33
+ "goal",
34
+ "constraints",
35
+ "decisions",
36
+ "completed",
37
+ "unverified",
38
+ "blockers",
39
+ "nextAction",
40
+ "sources",
41
+ "repo",
42
+ ],
43
+ properties: {
44
+ schemaVersion: { type: "integer", const: 1 },
45
+ goal: { type: "string" },
46
+ constraints: { type: "array", items: { type: "string" } },
47
+ decisions: {
48
+ type: "array",
49
+ minItems: 1,
50
+ items: {
51
+ type: "object",
52
+ additionalProperties: false,
53
+ required: ["decision", "reason"],
54
+ properties: {
55
+ decision: { type: "string" },
56
+ reason: { type: "string" },
57
+ status: { type: "string", enum: ["reported", "approved"] },
58
+ sourceRefs: { type: "array", minItems: 1, items: { type: "string" } },
59
+ supersedes: { type: "string" },
60
+ },
61
+ },
62
+ },
63
+ completed: { type: "array", items: { type: "string" } },
64
+ unverified: { type: "array", items: { type: "string" } },
65
+ blockers: { type: "array", items: { type: "string" } },
66
+ nextAction: { type: "string" },
67
+ sources: {
68
+ type: "array",
69
+ minItems: 1,
70
+ items: {
71
+ type: "object",
72
+ additionalProperties: false,
73
+ required: ["ref"],
74
+ properties: {
75
+ ref: { type: "string" },
76
+ label: { type: "string" },
77
+ kind: { type: "string", enum: ["agent_report", "capture", "agent_event"] },
78
+ sourceId: { type: "string" },
79
+ },
80
+ },
81
+ },
82
+ repo: {
83
+ type: "object",
84
+ additionalProperties: false,
85
+ required: ["branch", "commit", "dirty"],
86
+ properties: {
87
+ repository: { type: "string" },
88
+ branch: { type: "string" },
89
+ commit: { type: "string" },
90
+ dirty: { type: "boolean" },
91
+ worktreePath: { type: "string" },
92
+ dirtyFingerprint: { type: "string" },
93
+ },
94
+ description: "Working-tree metadata. This snapshot does not include file contents.",
95
+ },
96
+ },
97
+ };
98
+ function isObject(value) {
99
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
100
+ }
101
+ function unknownKey(value, allowed) {
102
+ for (const key of Object.keys(value)) {
103
+ if (!allowed.has(key))
104
+ return key;
105
+ }
106
+ return null;
107
+ }
108
+ function shortText(value, field, max) {
109
+ if (typeof value !== "string")
110
+ return { ok: false, error: `${field} must be a string` };
111
+ const cleaned = value.trim();
112
+ if (!cleaned)
113
+ return { ok: false, error: `${field} is required` };
114
+ if (cleaned.length > max)
115
+ return { ok: false, error: `${field} is too long` };
116
+ return { ok: true, value: cleaned };
117
+ }
118
+ function stringList(value, field, min) {
119
+ if (!Array.isArray(value))
120
+ return { ok: false, error: `${field} must be an array` };
121
+ if (value.length > MAX_ITEMS)
122
+ return { ok: false, error: `${field} has too many items` };
123
+ if (value.length < min)
124
+ return { ok: false, error: `${field} is required` };
125
+ const items = [];
126
+ for (const item of value) {
127
+ const parsed = shortText(item, field, MAX_SHORT);
128
+ if (!parsed.ok)
129
+ return parsed;
130
+ items.push(parsed.value);
131
+ }
132
+ return { ok: true, value: items };
133
+ }
134
+ export function parseRepoSnapshot(value) {
135
+ if (!isObject(value))
136
+ return { ok: false, error: "repo must be an object" };
137
+ const extra = unknownKey(value, new Set(["repository", "branch", "commit", "dirty", "worktreePath", "dirtyFingerprint"]));
138
+ if (extra)
139
+ return { ok: false, error: `repo.${extra} is not allowed` };
140
+ const branch = shortText(value.branch, "repo.branch", MAX_BRANCH);
141
+ if (!branch.ok)
142
+ return branch;
143
+ const commit = shortText(value.commit, "repo.commit", MAX_COMMIT);
144
+ if (!commit.ok)
145
+ return commit;
146
+ if (typeof value.dirty !== "boolean")
147
+ return { ok: false, error: "repo.dirty must be a boolean" };
148
+ const result = { branch: branch.value, commit: commit.value, dirty: value.dirty };
149
+ for (const field of ["repository", "worktreePath", "dirtyFingerprint"]) {
150
+ if (value[field] === undefined)
151
+ continue;
152
+ const parsed = shortText(value[field], `repo.${field}`, field === "worktreePath" ? 1_000 : MAX_SHORT);
153
+ if (!parsed.ok)
154
+ return parsed;
155
+ result[field] = parsed.value;
156
+ }
157
+ return { ok: true, value: result };
158
+ }
159
+ const PLACEHOLDER_PREFIX = "FILL:";
160
+ function isPlaceholder(value) {
161
+ return typeof value === "string" && value.trim().startsWith(PLACEHOLDER_PREFIX);
162
+ }
163
+ /** First field of a parsed proposal that still holds a template `FILL:` placeholder. */
164
+ export function findHandoffPlaceholder(proposal) {
165
+ if (isPlaceholder(proposal.goal))
166
+ return "goal";
167
+ for (const field of ["constraints", "completed", "unverified", "blockers"]) {
168
+ if (proposal[field].some(isPlaceholder))
169
+ return field;
170
+ }
171
+ for (const item of proposal.decisions) {
172
+ if (isPlaceholder(item.decision))
173
+ return "decisions.decision";
174
+ if (isPlaceholder(item.reason))
175
+ return "decisions.reason";
176
+ if (isPlaceholder(item.supersedes))
177
+ return "decisions.supersedes";
178
+ if (item.sourceRefs?.some(isPlaceholder))
179
+ return "decisions.sourceRefs";
180
+ }
181
+ if (isPlaceholder(proposal.nextAction))
182
+ return "nextAction";
183
+ for (const item of proposal.sources) {
184
+ if (isPlaceholder(item.ref))
185
+ return "sources.ref";
186
+ if (isPlaceholder(item.label))
187
+ return "sources.label";
188
+ if (isPlaceholder(item.sourceId))
189
+ return "sources.sourceId";
190
+ }
191
+ for (const field of ["repository", "branch", "commit", "worktreePath", "dirtyFingerprint"]) {
192
+ if (isPlaceholder(proposal.repo[field]))
193
+ return `repo.${field}`;
194
+ }
195
+ return null;
196
+ }
197
+ /**
198
+ * Validate a proposal. New writes reject template `FILL:` placeholders; pass
199
+ * `allowPlaceholders` only to re-read rows stored before that guard existed.
200
+ */
201
+ export function parseHandoffProposal(value, options = {}) {
202
+ if (!isObject(value))
203
+ return { ok: false, error: "proposal must be an object" };
204
+ const extra = unknownKey(value, PROPOSAL_KEYS);
205
+ if (extra)
206
+ return { ok: false, error: `proposal.${extra} is not allowed` };
207
+ if (value.schemaVersion !== HANDOFF_PROPOSAL_SCHEMA_VERSION) {
208
+ return { ok: false, error: "proposal.schemaVersion must be 1" };
209
+ }
210
+ const goal = shortText(value.goal, "goal", MAX_GOAL);
211
+ if (!goal.ok)
212
+ return goal;
213
+ const constraints = stringList(value.constraints, "constraints", 0);
214
+ if (!constraints.ok)
215
+ return constraints;
216
+ const completed = stringList(value.completed, "completed", 0);
217
+ if (!completed.ok)
218
+ return completed;
219
+ const unverified = stringList(value.unverified, "unverified", 0);
220
+ if (!unverified.ok)
221
+ return unverified;
222
+ const blockers = stringList(value.blockers, "blockers", 0);
223
+ if (!blockers.ok)
224
+ return blockers;
225
+ const nextAction = shortText(value.nextAction, "nextAction", MAX_SHORT);
226
+ if (!nextAction.ok)
227
+ return nextAction;
228
+ if (!Array.isArray(value.decisions))
229
+ return { ok: false, error: "decisions must be an array" };
230
+ if (value.decisions.length === 0)
231
+ return { ok: false, error: "decisions is required" };
232
+ if (value.decisions.length > MAX_ITEMS) {
233
+ return {
234
+ ok: false,
235
+ error: `A handoff keeps at most ${MAX_ITEMS} decisions; this one has ${value.decisions.length}. Replace an old one (name it in supersedes) or leave one out. The decision ledger comes in M2.`,
236
+ };
237
+ }
238
+ const decisions = [];
239
+ for (const item of value.decisions) {
240
+ if (!isObject(item))
241
+ return { ok: false, error: "decisions must be objects" };
242
+ const decisionExtra = unknownKey(item, new Set(["decision", "reason", "status", "sourceRefs", "supersedes"]));
243
+ if (decisionExtra)
244
+ return { ok: false, error: `decisions.${decisionExtra} is not allowed` };
245
+ const decision = shortText(item.decision, "decisions.decision", MAX_SHORT);
246
+ if (!decision.ok)
247
+ return decision;
248
+ const reason = shortText(item.reason, "decisions.reason", MAX_SHORT);
249
+ if (!reason.ok)
250
+ return reason;
251
+ const entry = { decision: decision.value, reason: reason.value };
252
+ if (item.status !== undefined) {
253
+ if (item.status !== "reported" && item.status !== "approved")
254
+ return { ok: false, error: "decisions.status is invalid" };
255
+ entry.status = item.status;
256
+ }
257
+ if (item.sourceRefs !== undefined) {
258
+ const sourceRefs = stringList(item.sourceRefs, "decisions.sourceRefs", 1);
259
+ if (!sourceRefs.ok)
260
+ return sourceRefs;
261
+ entry.sourceRefs = sourceRefs.value;
262
+ }
263
+ if (item.supersedes !== undefined) {
264
+ const supersedes = shortText(item.supersedes, "decisions.supersedes", MAX_SHORT);
265
+ if (!supersedes.ok)
266
+ return supersedes;
267
+ entry.supersedes = supersedes.value;
268
+ }
269
+ decisions.push(entry);
270
+ }
271
+ if (new Set(decisions.map((item) => item.decision.toLowerCase())).size !== decisions.length) {
272
+ return { ok: false, error: "decisions.decision must be unique" };
273
+ }
274
+ if (!Array.isArray(value.sources))
275
+ return { ok: false, error: "sources must be an array" };
276
+ if (value.sources.length === 0)
277
+ return { ok: false, error: "sources is required" };
278
+ if (value.sources.length > MAX_ITEMS)
279
+ return { ok: false, error: "sources has too many items" };
280
+ const sources = [];
281
+ for (const item of value.sources) {
282
+ if (!isObject(item))
283
+ return { ok: false, error: "sources must be objects" };
284
+ const sourceExtra = unknownKey(item, new Set(["ref", "label", "kind", "sourceId"]));
285
+ if (sourceExtra)
286
+ return { ok: false, error: `sources.${sourceExtra} is not allowed` };
287
+ const ref = shortText(item.ref, "sources.ref", MAX_SHORT);
288
+ if (!ref.ok)
289
+ return ref;
290
+ const source = { ref: ref.value };
291
+ if (item.label !== undefined) {
292
+ const label = shortText(item.label, "sources.label", MAX_SHORT);
293
+ if (!label.ok)
294
+ return label;
295
+ source.label = label.value;
296
+ }
297
+ if (item.kind !== undefined) {
298
+ if (item.kind !== "agent_report" && item.kind !== "capture" && item.kind !== "agent_event") {
299
+ return { ok: false, error: "sources.kind is invalid" };
300
+ }
301
+ source.kind = item.kind;
302
+ }
303
+ if (item.sourceId !== undefined) {
304
+ const sourceId = shortText(item.sourceId, "sources.sourceId", MAX_SHORT);
305
+ if (!sourceId.ok)
306
+ return sourceId;
307
+ source.sourceId = sourceId.value;
308
+ }
309
+ if ((source.kind === "capture" || source.kind === "agent_event") !== Boolean(source.sourceId)) {
310
+ return { ok: false, error: "Stored sources require a sourceId; agent reports cannot have one" };
311
+ }
312
+ sources.push(source);
313
+ }
314
+ const refs = new Set(sources.map((source) => source.ref));
315
+ if (refs.size !== sources.length)
316
+ return { ok: false, error: "sources.ref must be unique" };
317
+ for (const decision of decisions) {
318
+ if (decision.sourceRefs?.some((ref) => !refs.has(ref))) {
319
+ return { ok: false, error: "decisions.sourceRefs must refer to proposal sources" };
320
+ }
321
+ }
322
+ const repo = parseRepoSnapshot(value.repo);
323
+ if (!repo.ok)
324
+ return repo;
325
+ const proposal = {
326
+ schemaVersion: 1,
327
+ goal: goal.value,
328
+ constraints: constraints.value,
329
+ decisions,
330
+ completed: completed.value,
331
+ unverified: unverified.value,
332
+ blockers: blockers.value,
333
+ nextAction: nextAction.value,
334
+ sources,
335
+ repo: repo.value,
336
+ };
337
+ if (!options.allowPlaceholders) {
338
+ const placeholder = findHandoffPlaceholder(proposal);
339
+ if (placeholder)
340
+ return { ok: false, error: `${placeholder} still contains a FILL: placeholder` };
341
+ // Saved text reaches every later session, so a secret is refused, never stored.
342
+ const secret = findSecretInValue(proposal);
343
+ if (secret)
344
+ return { ok: false, error: `${secret.field} looks like ${secret.shape}. Remove it; Hypher never stores secrets.` };
345
+ }
346
+ return { ok: true, value: proposal };
347
+ }
348
+ export function renderHandoffPacket(proposal) {
349
+ const lines = [
350
+ `Goal: ${proposal.goal}`,
351
+ proposal.constraints.length ? `Constraints: ${proposal.constraints.join("; ")}` : "Constraints: none",
352
+ "Decisions:",
353
+ ...proposal.decisions.map((item) => `- ${item.status === "approved" ? "Approved" : "Agent-reported"}: ${item.decision} — ${item.reason}${item.sourceRefs?.length ? ` [${item.sourceRefs.join(", ")}]` : ""}${item.supersedes ? ` (supersedes: ${item.supersedes})` : ""}`),
354
+ proposal.completed.length ? `Agent-reported completed work: ${proposal.completed.join("; ")}` : "Agent-reported completed work: none",
355
+ proposal.unverified.length ? `Unverified: ${proposal.unverified.join("; ")}` : "Unverified: none",
356
+ proposal.blockers.length ? `Blockers: ${proposal.blockers.join("; ")}` : "Blockers: none",
357
+ `Next action: ${proposal.nextAction}`,
358
+ "Sources:",
359
+ ...proposal.sources.map((item) => `- ${item.kind === "capture" ? "Project capture" : item.kind === "agent_event" ? "Stored agent report" : "Agent-reported"}: ${item.label ? `${item.label}: ` : ""}${item.ref}`),
360
+ `Repo: ${proposal.repo.repository ?? "identity unavailable"}, branch ${proposal.repo.branch}, commit ${proposal.repo.commit}, dirty ${proposal.repo.dirty}`,
361
+ "Memory does not transfer code or uncommitted files.",
362
+ ];
363
+ return lines.join("\n");
364
+ }
365
+ export function compareRepoSnapshot(saved, current) {
366
+ const parts = [];
367
+ if (!saved.repository || !current.repository || saved.repository !== current.repository) {
368
+ parts.push(`repository is ${current.repository ?? "unknown"}; handoff recorded ${saved.repository ?? "unknown"}`);
369
+ }
370
+ if (saved.branch !== current.branch) {
371
+ parts.push(`branch is ${current.branch}; handoff recorded ${saved.branch}`);
372
+ }
373
+ if (saved.commit !== current.commit) {
374
+ parts.push(`commit is ${current.commit}; handoff recorded ${saved.commit}`);
375
+ }
376
+ if (saved.dirty !== current.dirty) {
377
+ parts.push(`dirty is ${String(current.dirty)}; handoff recorded ${String(saved.dirty)}`);
378
+ }
379
+ if (saved.worktreePath && current.worktreePath && saved.worktreePath !== current.worktreePath) {
380
+ parts.push("local worktree path differs");
381
+ }
382
+ if (saved.dirty && (!saved.dirtyFingerprint || !current.dirtyFingerprint || saved.dirtyFingerprint !== current.dirtyFingerprint)) {
383
+ parts.push("dirty file contents were not verified as the same");
384
+ }
385
+ if (parts.length === 0)
386
+ return { match: true, warning: "Supplied repository metadata matches. Inspect the local working tree before continuing; Hypher did not verify file contents." };
387
+ return {
388
+ match: false,
389
+ warning: `Supplied repository metadata does not establish the same working tree (${parts.join("; ")}). Memory did not transfer code or uncommitted files. Compare the tree yourself before continuing.`,
390
+ };
391
+ }
392
+ function integerRevision(value) {
393
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 1_000_000) {
394
+ return { ok: false, error: "expectedBaseRevision must be a non-negative integer" };
395
+ }
396
+ return { ok: true, value };
397
+ }
398
+ export function commitStructuredHandoff(input) {
399
+ const key = input.idempotencyKey.trim();
400
+ if (!key || key.length > MAX_KEY) {
401
+ return {
402
+ ok: false,
403
+ status: 400,
404
+ code: "invalid",
405
+ error: "idempotencyKey is required",
406
+ headRevision: input.headRevision,
407
+ };
408
+ }
409
+ if (input.existingKeyRevision !== null) {
410
+ return {
411
+ ok: false,
412
+ status: 409,
413
+ code: "duplicate",
414
+ error: `Duplicate handoff write. Idempotency key already stored at revision ${input.existingKeyRevision}.`,
415
+ headRevision: input.headRevision,
416
+ revision: input.existingKeyRevision,
417
+ };
418
+ }
419
+ if (input.expectedBaseRevision !== input.headRevision) {
420
+ return {
421
+ ok: false,
422
+ status: 409,
423
+ code: "stale-revision",
424
+ error: `Stale handoff revision. Expected base ${input.expectedBaseRevision}, current revision is ${input.headRevision}.`,
425
+ headRevision: input.headRevision,
426
+ };
427
+ }
428
+ const revision = input.headRevision + 1;
429
+ return {
430
+ ok: true,
431
+ head: {
432
+ projectId: input.projectId,
433
+ revision,
434
+ idempotencyKey: key,
435
+ proposal: input.proposal,
436
+ source: input.source,
437
+ savedAt: input.savedAt,
438
+ },
439
+ };
440
+ }
441
+ /** A revision is a full current snapshot; prior decisions need an explicit sourced retirement. */
442
+ export function validateDecisionTransition(previous, next, approvedSourceRefs) {
443
+ const prior = new Map((previous?.decisions ?? []).map((item) => [item.decision.toLowerCase(), item]));
444
+ const current = new Set(next.decisions.map((item) => item.decision.toLowerCase()));
445
+ const superseded = new Set();
446
+ for (const item of next.decisions) {
447
+ const oldDecision = prior.get(item.decision.toLowerCase());
448
+ if (oldDecision?.status === "approved" && item.status !== "approved") {
449
+ return `Approved decision cannot be downgraded: ${item.decision}`;
450
+ }
451
+ const linkedApproval = item.sourceRefs?.some((ref) => approvedSourceRefs.has(ref)) ?? false;
452
+ if (item.status === "approved" && !linkedApproval) {
453
+ return `Approved decision requires a linked, project-owned pinned decision capture: ${item.decision}`;
454
+ }
455
+ if (!item.supersedes)
456
+ continue;
457
+ const old = item.supersedes.toLowerCase();
458
+ // Carried forward with the same supersession: that was checked when it was recorded.
459
+ if (oldDecision && oldDecision.supersedes?.toLowerCase() === old) {
460
+ if (current.has(old))
461
+ return `Invalid decision supersession: ${item.supersedes}`;
462
+ continue;
463
+ }
464
+ const retired = prior.get(old);
465
+ if (!retired || current.has(old) || superseded.has(old)) {
466
+ return `Invalid decision supersession: ${item.supersedes}`;
467
+ }
468
+ if (retired.status === "approved" && (item.status !== "approved" || !linkedApproval)) {
469
+ return `Approved decision supersession requires an approved, sourced decision: ${item.decision}`;
470
+ }
471
+ if (!item.sourceRefs?.length) {
472
+ return `Decision supersession requires a linked source: ${item.decision}`;
473
+ }
474
+ superseded.add(old);
475
+ }
476
+ for (const item of previous?.decisions ?? []) {
477
+ const key = item.decision.toLowerCase();
478
+ if (!current.has(key) && !superseded.has(key)) {
479
+ return `Decision cannot disappear without sourced supersession: ${item.decision}`;
480
+ }
481
+ }
482
+ return null;
483
+ }
484
+ export function planHandoffDelivery(input) {
485
+ if (input.destinationProjectId !== input.handoffProjectId) {
486
+ return {
487
+ ok: false,
488
+ status: 400,
489
+ code: "wrong-project",
490
+ error: "Destination project does not match the handoff project.",
491
+ };
492
+ }
493
+ const destination = input.destination.trim();
494
+ if (!destination || destination.length > MAX_DESTINATION) {
495
+ return { ok: false, status: 400, code: "invalid", error: "destination is required" };
496
+ }
497
+ if (!input.head) {
498
+ return {
499
+ ok: false,
500
+ status: 400,
501
+ code: "no-handoff",
502
+ error: "No structured handoff is stored for this project.",
503
+ };
504
+ }
505
+ const comparison = compareRepoSnapshot(input.head.proposal.repo, input.currentRepo);
506
+ const reason = input.reason?.trim();
507
+ if (reason && reason.length > MAX_REASON) {
508
+ return { ok: false, status: 400, code: "invalid", error: "reason is too long" };
509
+ }
510
+ const receipt = {
511
+ projectId: input.handoffProjectId,
512
+ revision: input.head.revision,
513
+ destination,
514
+ result: input.result,
515
+ reason: reason || undefined,
516
+ repoWarning: comparison.warning,
517
+ createdAt: input.createdAt,
518
+ };
519
+ if (input.result === "failed") {
520
+ return {
521
+ ok: false,
522
+ status: 409,
523
+ code: "failed-delivery",
524
+ error: reason || "Handoff delivery failed. The last valid handoff was preserved.",
525
+ preservedRevision: input.head.revision,
526
+ proposal: input.head.proposal,
527
+ repoSnapshot: input.head.proposal.repo,
528
+ repoMatch: comparison.match,
529
+ warning: comparison.warning,
530
+ transfersCode: false,
531
+ checksOut: false,
532
+ receipt,
533
+ };
534
+ }
535
+ return {
536
+ ok: true,
537
+ status: 200,
538
+ code: "prepared",
539
+ revision: input.head.revision,
540
+ proposal: input.head.proposal,
541
+ repoSnapshot: input.head.proposal.repo,
542
+ repoMatch: comparison.match,
543
+ warning: comparison.warning,
544
+ transfersCode: false,
545
+ checksOut: false,
546
+ receipt,
547
+ };
548
+ }
549
+ export function readHandoffCommand(value) {
550
+ if (!isObject(value))
551
+ return { ok: true, command: null };
552
+ const hasSave = "proposal" in value || "expectedBaseRevision" in value || "idempotencyKey" in value;
553
+ const hasResume = "resume" in value && value.resume !== undefined;
554
+ const hasAcknowledge = "acknowledge" in value && value.acknowledge !== undefined;
555
+ if (Number(hasSave) + Number(hasResume) + Number(hasAcknowledge) > 1) {
556
+ return { ok: false, error: "Send one handoff save, resume, or acknowledgment." };
557
+ }
558
+ if (!hasSave && !hasResume && !hasAcknowledge)
559
+ return { ok: true, command: null };
560
+ const projectId = typeof value.projectId === "string" && value.projectId.trim()
561
+ ? value.projectId.trim()
562
+ : undefined;
563
+ if (hasAcknowledge) {
564
+ if (!isObject(value.acknowledge))
565
+ return { ok: false, error: "acknowledge must be an object" };
566
+ const extra = unknownKey(value.acknowledge, new Set(["receiptId", "revision", "destination"]));
567
+ if (extra)
568
+ return { ok: false, error: `acknowledge.${extra} is not allowed` };
569
+ const receiptId = shortText(value.acknowledge.receiptId, "acknowledge.receiptId", MAX_KEY);
570
+ if (!receiptId.ok)
571
+ return receiptId;
572
+ const destination = shortText(value.acknowledge.destination, "acknowledge.destination", MAX_DESTINATION);
573
+ if (!destination.ok)
574
+ return destination;
575
+ const revision = integerRevision(value.acknowledge.revision);
576
+ if (!revision.ok || revision.value < 1)
577
+ return { ok: false, error: "acknowledge.revision must be positive" };
578
+ return { ok: true, command: { type: "acknowledge", projectId, receiptId: receiptId.value,
579
+ revision: revision.value, destination: destination.value } };
580
+ }
581
+ if (hasResume) {
582
+ if (!isObject(value.resume))
583
+ return { ok: false, error: "resume must be an object" };
584
+ const extra = unknownKey(value.resume, new Set(["destination", "destinationProjectId", "currentRepo", "result", "reason"]));
585
+ if (extra)
586
+ return { ok: false, error: `resume.${extra} is not allowed` };
587
+ const destination = shortText(value.resume.destination, "destination", MAX_DESTINATION);
588
+ if (!destination.ok)
589
+ return destination;
590
+ const currentRepo = parseRepoSnapshot(value.resume.currentRepo);
591
+ if (!currentRepo.ok)
592
+ return currentRepo;
593
+ let result = "prepared";
594
+ if (value.resume.result !== undefined) {
595
+ if (value.resume.result !== "delivered" && value.resume.result !== "prepared" && value.resume.result !== "failed") {
596
+ return { ok: false, error: "resume.result must be prepared or failed" };
597
+ }
598
+ result = value.resume.result === "delivered" ? "prepared" : value.resume.result;
599
+ }
600
+ let reason;
601
+ if (value.resume.reason !== undefined) {
602
+ const parsedReason = shortText(value.resume.reason, "reason", MAX_REASON);
603
+ if (!parsedReason.ok)
604
+ return parsedReason;
605
+ reason = parsedReason.value;
606
+ }
607
+ let destinationProjectId;
608
+ if (value.resume.destinationProjectId !== undefined) {
609
+ if (typeof value.resume.destinationProjectId !== "string" || !value.resume.destinationProjectId.trim()) {
610
+ return { ok: false, error: "destinationProjectId must be a string" };
611
+ }
612
+ destinationProjectId = value.resume.destinationProjectId.trim();
613
+ }
614
+ return {
615
+ ok: true,
616
+ command: {
617
+ type: "resume",
618
+ projectId,
619
+ destination: destination.value,
620
+ destinationProjectId,
621
+ currentRepo: currentRepo.value,
622
+ result,
623
+ reason,
624
+ },
625
+ };
626
+ }
627
+ if (!("proposal" in value)) {
628
+ return { ok: false, error: "Structured handoff save requires a proposal." };
629
+ }
630
+ const proposal = parseHandoffProposal(value.proposal);
631
+ if (!proposal.ok)
632
+ return proposal;
633
+ let expectedBaseRevision;
634
+ if (value.expectedBaseRevision !== undefined) {
635
+ const parsedBase = integerRevision(value.expectedBaseRevision);
636
+ if (!parsedBase.ok)
637
+ return parsedBase;
638
+ expectedBaseRevision = parsedBase.value;
639
+ }
640
+ let idempotencyKey;
641
+ if (value.idempotencyKey !== undefined) {
642
+ const parsedKey = shortText(value.idempotencyKey, "idempotencyKey", MAX_KEY);
643
+ if (!parsedKey.ok)
644
+ return parsedKey;
645
+ idempotencyKey = parsedKey.value;
646
+ }
647
+ if (value.kind !== undefined && value.kind !== "handoff") {
648
+ return { ok: false, error: "Structured handoff save requires kind handoff." };
649
+ }
650
+ return {
651
+ ok: true,
652
+ command: {
653
+ type: "save",
654
+ projectId,
655
+ ...(expectedBaseRevision !== undefined ? { expectedBaseRevision } : {}),
656
+ ...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
657
+ proposal: proposal.value,
658
+ eventDefaults: {
659
+ kind: "handoff",
660
+ title: `Handoff: ${proposal.value.goal}`.slice(0, 200),
661
+ body: renderHandoffPacket(proposal.value).slice(0, 9_000),
662
+ },
663
+ },
664
+ };
665
+ }
666
+ export function parseResumeCall(args) {
667
+ const destination = shortText(args.destination, "destination", MAX_DESTINATION);
668
+ if (!destination.ok)
669
+ return destination;
670
+ const currentRepo = parseRepoSnapshot(args.currentRepo);
671
+ if (!currentRepo.ok)
672
+ return currentRepo;
673
+ let result = "prepared";
674
+ if (args.deliveryResult !== undefined) {
675
+ if (args.deliveryResult !== "delivered" && args.deliveryResult !== "prepared" && args.deliveryResult !== "failed") {
676
+ return { ok: false, error: "deliveryResult must be prepared or failed" };
677
+ }
678
+ result = args.deliveryResult === "delivered" ? "prepared" : args.deliveryResult;
679
+ }
680
+ let reason;
681
+ if (args.reason !== undefined) {
682
+ const parsed = shortText(args.reason, "reason", MAX_REASON);
683
+ if (!parsed.ok)
684
+ return parsed;
685
+ reason = parsed.value;
686
+ }
687
+ let destinationProjectId = args.projectId;
688
+ if (args.destinationProjectId !== undefined) {
689
+ if (typeof args.destinationProjectId !== "string" || !args.destinationProjectId.trim()) {
690
+ return { ok: false, error: "destinationProjectId must be a string" };
691
+ }
692
+ destinationProjectId = args.destinationProjectId.trim();
693
+ }
694
+ return {
695
+ ok: true,
696
+ value: {
697
+ projectId: args.projectId,
698
+ destinationProjectId,
699
+ destination: destination.value,
700
+ currentRepo: currentRepo.value,
701
+ result,
702
+ reason,
703
+ },
704
+ };
705
+ }
706
+ /**
707
+ * The base revision for a save that did not name one: the revision this
708
+ * caller was last handed (its latest prepared receipt), never whatever is
709
+ * newest now, so a stale save is still rejected. Null means resume first.
710
+ */
711
+ export function baseRevisionForSave(input) {
712
+ if (input.expectedBaseRevision !== undefined)
713
+ return { ok: true, value: input.expectedBaseRevision };
714
+ if (input.lastHandedRevision !== null)
715
+ return { ok: true, value: input.lastHandedRevision };
716
+ if (input.headRevision === 0)
717
+ return { ok: true, value: 0 };
718
+ return {
719
+ ok: false,
720
+ error: `A handoff (revision ${input.headRevision}) exists that this session has not read. Resume first (prepare_handoff with currentRepo), then save.`,
721
+ };
722
+ }
723
+ /** Stable JSON: keys sorted, undefined dropped. */
724
+ export function canonicalJson(value) {
725
+ if (Array.isArray(value))
726
+ return `[${value.map(canonicalJson).join(",")}]`;
727
+ if (value && typeof value === "object") {
728
+ const record = value;
729
+ return `{${Object.keys(record).sort().filter((key) => record[key] !== undefined)
730
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
731
+ }
732
+ return JSON.stringify(value);
733
+ }
734
+ /** SHA-256 hex in plain JavaScript, so Convex queries, mutations, and Node agree. */
735
+ export function sha256Hex(text) {
736
+ const bytes = new TextEncoder().encode(text);
737
+ const K = new Uint32Array([
738
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
739
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
740
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
741
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
742
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
743
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
744
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
745
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
746
+ ]);
747
+ const H = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
748
+ const length = bytes.length;
749
+ const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6);
750
+ padded.set(bytes);
751
+ padded[length] = 0x80;
752
+ const view = new DataView(padded.buffer);
753
+ view.setUint32(padded.length - 8, Math.floor(length / 0x20000000));
754
+ view.setUint32(padded.length - 4, (length << 3) >>> 0);
755
+ const W = new Uint32Array(64);
756
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
757
+ for (let offset = 0; offset < padded.length; offset += 64) {
758
+ for (let t = 0; t < 16; t += 1)
759
+ W[t] = view.getUint32(offset + t * 4);
760
+ for (let t = 16; t < 64; t += 1) {
761
+ const s0 = rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3);
762
+ const s1 = rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10);
763
+ W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0;
764
+ }
765
+ let [a, b, c, d, e, f, g, h] = H;
766
+ for (let t = 0; t < 64; t += 1) {
767
+ const t1 = (h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[t] + W[t]) >>> 0;
768
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
769
+ h = g;
770
+ g = f;
771
+ f = e;
772
+ e = (d + t1) >>> 0;
773
+ d = c;
774
+ c = b;
775
+ b = a;
776
+ a = (t1 + t2) >>> 0;
777
+ }
778
+ H[0] = (H[0] + a) >>> 0;
779
+ H[1] = (H[1] + b) >>> 0;
780
+ H[2] = (H[2] + c) >>> 0;
781
+ H[3] = (H[3] + d) >>> 0;
782
+ H[4] = (H[4] + e) >>> 0;
783
+ H[5] = (H[5] + f) >>> 0;
784
+ H[6] = (H[6] + g) >>> 0;
785
+ H[7] = (H[7] + h) >>> 0;
786
+ }
787
+ return Array.from(H, (word) => word.toString(16).padStart(8, "0")).join("");
788
+ }
789
+ /**
790
+ * The key for a save that did not name one: the base revision plus the full
791
+ * request. A retry dedupes; a change that returns to earlier content has a
792
+ * newer base, so it is not mistaken for a duplicate.
793
+ */
794
+ export function defaultIdempotencyKey(baseRevision, request) {
795
+ return `auto-${sha256Hex(`${baseRevision}\n${canonicalJson(request)}`).slice(0, 48)}`;
796
+ }