@litterthanlit/hypher-cli 0.1.7 → 0.1.8
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.
- package/package.json +1 -1
- package/src/env.mjs +1 -1
- package/src/session.mjs +14 -5
- package/vendor/handoffNote.mjs +1 -0
- package/vendor/handoffSeal.mjs +28 -3
- package/vendor/structuredHandoff.mjs +266 -0
package/package.json
CHANGED
package/src/env.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import os from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
9
|
// Keep equal to package.json; a test checks it.
|
|
10
|
-
export const CLI_VERSION = "0.1.
|
|
10
|
+
export const CLI_VERSION = "0.1.8";
|
|
11
11
|
export const DEFAULT_API_BASE = "https://www.hypher.app";
|
|
12
12
|
export const INTEGRATIONS_URL = "https://hypher.app/app/settings/integrations";
|
|
13
13
|
export const API_KEYS_URL = "https://hypher.app/app/settings/api-keys";
|
package/src/session.mjs
CHANGED
|
@@ -17,13 +17,18 @@ import {
|
|
|
17
17
|
renderNoHandoffNote,
|
|
18
18
|
renderUnlinkedLine,
|
|
19
19
|
} from "../vendor/handoffNote.mjs";
|
|
20
|
-
import { buildSealProposal } from "../vendor/handoffSeal.mjs";
|
|
21
|
-
import {
|
|
20
|
+
import { buildSealProposal, CLI_UPDATE_COMMAND, readOnlyHandoffMessage } from "../vendor/handoffSeal.mjs";
|
|
21
|
+
import { parseHandoffProposalLenient } from "../vendor/structuredHandoff.mjs";
|
|
22
22
|
|
|
23
23
|
// The MCP tool first: in an agent's shell `hypher` may be off PATH or inside Codex's sandbox.
|
|
24
24
|
export const ACK_HINT = "call the `acknowledge` tool of the `hypher` MCP server, or run `hypher ack`";
|
|
25
25
|
const NOT_CONNECTED_LINE = "Hypher is not connected on this computer; starting without it. Run `hypher init` in a terminal.";
|
|
26
26
|
|
|
27
|
+
/** Said at the top of the note when the handoff is newer than this copy of Hypher can save over. */
|
|
28
|
+
export function readOnlyNotice(version) {
|
|
29
|
+
return `Hypher: this handoff is read-only here. It is format version ${version}, newer than this copy of Hypher can save over, so a save would be refused and nothing would be sent. Use the note as it is. To save again, run \`${CLI_UPDATE_COMMAND}\` in a terminal.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
/** The agent running this command, from its environment. */
|
|
28
33
|
export function detectAgent(env, fallback) {
|
|
29
34
|
if (env.HYPHER_AGENT) return String(env.HYPHER_AGENT).slice(0, 40);
|
|
@@ -104,8 +109,9 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
|
|
|
104
109
|
return { status: "rejected", note: `Hypher: ${prepared?.error ?? "could not load the handoff"}. Run \`hypher doctor\`.`, snapshot };
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
// Read leniently: a field, a longer list, or a newer version written by a newer Hypher is not a reason to refuse the note.
|
|
113
|
+
const parsed = parseHandoffProposalLenient(prepared.proposal);
|
|
114
|
+
if (!parsed.ok) return { status: "rejected", note: `Hypher returned a handoff this CLI cannot read. Update with \`${CLI_UPDATE_COMMAND}\`.`, snapshot };
|
|
109
115
|
const proposal = parsed.value;
|
|
110
116
|
const verdict = truthCheck(snapshot.root, proposal.repo);
|
|
111
117
|
const savedAt = typeof prepared.savedAt === "number" ? prepared.savedAt : undefined;
|
|
@@ -121,6 +127,7 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
|
|
|
121
127
|
currentTree: verdict?.current?.tree ?? snapshot.tree,
|
|
122
128
|
checkpoint: shownCheckpoint,
|
|
123
129
|
acknowledge: ACK_HINT,
|
|
130
|
+
notice: parsed.readOnly ? readOnlyNotice(proposal.schemaVersion) : null,
|
|
124
131
|
});
|
|
125
132
|
updateWorktreeState(env, snapshot.root, (state) => withBranchState({
|
|
126
133
|
...state,
|
|
@@ -174,7 +181,9 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
|
|
|
174
181
|
if (!state || state.projectId !== link.projectId || typeof state.baseRevision !== "number") {
|
|
175
182
|
return { ok: false, code: "no-base", message: "Run `hypher resume` first, so this save is based on the handoff you read." };
|
|
176
183
|
}
|
|
177
|
-
const prior = state.proposal ?
|
|
184
|
+
const prior = state.proposal ? parseHandoffProposalLenient(state.proposal) : null;
|
|
185
|
+
// A newer format than this copy can write: refused before anything is built or sent.
|
|
186
|
+
if (prior?.ok && prior.readOnly) return { ok: false, code: "read-only", message: readOnlyHandoffMessage(prior.value.schemaVersion) };
|
|
178
187
|
const iso = new Date(now).toISOString().slice(0, 16).replace("T", " ");
|
|
179
188
|
// Checks are bound to the working-tree hash at this save. Without one, the handoff still
|
|
180
189
|
// saves; the checks are left out and the agent is told.
|
package/vendor/handoffNote.mjs
CHANGED
|
@@ -135,6 +135,7 @@ function sections(input, max, listLimit) {
|
|
|
135
135
|
header,
|
|
136
136
|
NOTE_FIRST_REPLY,
|
|
137
137
|
NOTE_PREAMBLE,
|
|
138
|
+
...(input.notice ? [noteText(input.notice, 500)] : []),
|
|
138
139
|
input.verdict ? describeVerdict(input.verdict)
|
|
139
140
|
: "Tree: not checked here. Compare the repository, branch, commit, and uncommitted files yourself before continuing.",
|
|
140
141
|
`Constraints: ${joinItems(limit(proposal.constraints), "none recorded.", max)}${more(proposal.constraints)}`,
|
package/vendor/handoffSeal.mjs
CHANGED
|
@@ -6,8 +6,17 @@
|
|
|
6
6
|
* adds a source for this session, and maps short decision references ("D2",
|
|
7
7
|
* or a unique part of the text) to the exact prior text for `supersedes`.
|
|
8
8
|
*/
|
|
9
|
-
import { HANDOFF_PROPOSAL_SCHEMA_VERSION, parseHandoffProposal, } from "./structuredHandoff.mjs";
|
|
9
|
+
import { HANDOFF_PROPOSAL_SCHEMA_VERSION, knownFieldsV1, parseHandoffProposal, reattachUnknownFields, } from "./structuredHandoff.mjs";
|
|
10
10
|
export const MAX_HANDOFF_ITEMS = 8;
|
|
11
|
+
/**
|
|
12
|
+
* What an installed copy runs to update itself. `hypher init` from an installed copy finds itself
|
|
13
|
+
* already at its own path and keeps the same version; the `@latest` fetch is what updates.
|
|
14
|
+
*/
|
|
15
|
+
export const CLI_UPDATE_COMMAND = "npx @litterthanlit/hypher-cli@latest init";
|
|
16
|
+
/** Why a save on top of a newer-format handoff is refused. One line; nothing was sent. */
|
|
17
|
+
export function readOnlyHandoffMessage(version) {
|
|
18
|
+
return `This handoff is format version ${version}, newer than this copy of Hypher can save over, so nothing was sent. Update with \`${CLI_UPDATE_COMMAND}\` in a terminal, then save again.`;
|
|
19
|
+
}
|
|
11
20
|
function clean(value) {
|
|
12
21
|
return typeof value === "string" ? value.trim() : "";
|
|
13
22
|
}
|
|
@@ -103,11 +112,23 @@ function tooMany(field, count, hint) {
|
|
|
103
112
|
return `A handoff keeps at most ${MAX_HANDOFF_ITEMS} ${field}; this one would have ${count}. ${hint}`;
|
|
104
113
|
}
|
|
105
114
|
/**
|
|
115
|
+
* Carry rule: a save sends what it knows plus what it was handed, unchanged. Fields of the prior
|
|
116
|
+
* handoff that this code does not know are re-attached as they were: the top-level ones always,
|
|
117
|
+
* and those on a decision, source, or evidence item for as long as that item is carried forward.
|
|
118
|
+
* They leave with the item when it is replaced or dropped, because its owner was removed on
|
|
119
|
+
* purpose. Nothing this code did not read is invented or edited, and `repo` is replaced. The
|
|
120
|
+
* known fields are built only from the seal input and checked by the strict parser; the carried
|
|
121
|
+
* ones are not checked here, and the server refuses by name what it does not accept. A prior
|
|
122
|
+
* newer than version 1 is read-only: the save is refused.
|
|
123
|
+
*
|
|
106
124
|
* @param prior the proposal this session resumed from (null for the first handoff)
|
|
107
125
|
* @param sessionRef a unique label for this session, used as its source
|
|
108
126
|
*/
|
|
109
127
|
export function buildSealProposal(input) {
|
|
110
128
|
const { prior, seal } = input;
|
|
129
|
+
if (prior && prior.schemaVersion !== HANDOFF_PROPOSAL_SCHEMA_VERSION) {
|
|
130
|
+
return { ok: false, error: readOnlyHandoffMessage(prior.schemaVersion) };
|
|
131
|
+
}
|
|
111
132
|
const nextAction = clean(seal.nextAction);
|
|
112
133
|
if (!nextAction)
|
|
113
134
|
return { ok: false, error: "The next action is required: nextAction (in a shell, --next \"…\")." };
|
|
@@ -200,8 +221,12 @@ export function buildSealProposal(input) {
|
|
|
200
221
|
return { ok: false, error: tooMany(field, proposal[field].length, "Keep the ones the next agent needs.") };
|
|
201
222
|
}
|
|
202
223
|
}
|
|
203
|
-
|
|
224
|
+
// The strict parser sees only the fields this code knows; the carried ones go back afterwards.
|
|
225
|
+
const sendable = knownFieldsV1(proposal);
|
|
226
|
+
if (!sendable)
|
|
227
|
+
return { ok: false, error: readOnlyHandoffMessage(proposal.schemaVersion) };
|
|
228
|
+
const parsed = parseHandoffProposal(sendable);
|
|
204
229
|
if (!parsed.ok)
|
|
205
230
|
return parsed;
|
|
206
|
-
return { ok: true, proposal: parsed.value };
|
|
231
|
+
return { ok: true, proposal: reattachUnknownFields(parsed.value, { ...prior, ...proposal }) };
|
|
207
232
|
}
|
|
@@ -409,6 +409,248 @@ export function parseHandoffProposal(value, options = {}) {
|
|
|
409
409
|
}
|
|
410
410
|
return { ok: true, value: proposal };
|
|
411
411
|
}
|
|
412
|
+
const DECISION_KEYS = new Set(["decision", "reason", "status", "sourceRefs", "supersedes"]);
|
|
413
|
+
const SOURCE_KEYS = new Set(["ref", "label", "kind", "sourceId"]);
|
|
414
|
+
const REPO_KEYS = new Set(["repository", "branch", "commit", "dirty", "worktreePath", "dirtyFingerprint"]);
|
|
415
|
+
/** Required text, trimmed, with no length cap. */
|
|
416
|
+
function readText(value, field) {
|
|
417
|
+
return shortText(value, field, Number.POSITIVE_INFINITY);
|
|
418
|
+
}
|
|
419
|
+
function readTextList(value, field) {
|
|
420
|
+
if (!Array.isArray(value))
|
|
421
|
+
return { ok: false, error: `${field} must be an array` };
|
|
422
|
+
const items = [];
|
|
423
|
+
for (const item of value) {
|
|
424
|
+
const parsed = readText(item, field);
|
|
425
|
+
if (!parsed.ok)
|
|
426
|
+
return parsed;
|
|
427
|
+
items.push(parsed.value);
|
|
428
|
+
}
|
|
429
|
+
return { ok: true, value: items };
|
|
430
|
+
}
|
|
431
|
+
/** The own fields of `value` that are not in `known`, in their original order. */
|
|
432
|
+
function unknownFieldsOf(value, known) {
|
|
433
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !known.has(key)));
|
|
434
|
+
}
|
|
435
|
+
function knownFieldsOnly(value, known) {
|
|
436
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => known.has(key)));
|
|
437
|
+
}
|
|
438
|
+
function readRepo(value) {
|
|
439
|
+
if (!isObject(value))
|
|
440
|
+
return { ok: false, error: "repo must be an object" };
|
|
441
|
+
const branch = readText(value.branch, "repo.branch");
|
|
442
|
+
if (!branch.ok)
|
|
443
|
+
return branch;
|
|
444
|
+
const commit = readText(value.commit, "repo.commit");
|
|
445
|
+
if (!commit.ok)
|
|
446
|
+
return commit;
|
|
447
|
+
if (typeof value.dirty !== "boolean")
|
|
448
|
+
return { ok: false, error: "repo.dirty must be a boolean" };
|
|
449
|
+
const result = { branch: branch.value, commit: commit.value, dirty: value.dirty };
|
|
450
|
+
for (const field of ["repository", "worktreePath", "dirtyFingerprint"]) {
|
|
451
|
+
if (value[field] === undefined)
|
|
452
|
+
continue;
|
|
453
|
+
const parsed = readText(value[field], `repo.${field}`);
|
|
454
|
+
if (!parsed.ok)
|
|
455
|
+
return parsed;
|
|
456
|
+
result[field] = parsed.value;
|
|
457
|
+
}
|
|
458
|
+
return { ok: true, value: result };
|
|
459
|
+
}
|
|
460
|
+
function readEvidence(value) {
|
|
461
|
+
if (!Array.isArray(value))
|
|
462
|
+
return { ok: false, error: "evidence must be an array" };
|
|
463
|
+
const items = [];
|
|
464
|
+
for (const item of value) {
|
|
465
|
+
if (!isObject(item))
|
|
466
|
+
return { ok: false, error: "evidence must be objects" };
|
|
467
|
+
const command = readText(item.command, "evidence.command");
|
|
468
|
+
if (!command.ok)
|
|
469
|
+
return command;
|
|
470
|
+
if (typeof item.exitCode !== "number" || !Number.isFinite(item.exitCode)) {
|
|
471
|
+
return { ok: false, error: "evidence.exitCode must be a number" };
|
|
472
|
+
}
|
|
473
|
+
const tree = readText(item.tree, "evidence.tree");
|
|
474
|
+
if (!tree.ok)
|
|
475
|
+
return tree;
|
|
476
|
+
if (item.capturedBy !== "agent" && item.capturedBy !== "hook") {
|
|
477
|
+
return { ok: false, error: "evidence.capturedBy must be agent or hook" };
|
|
478
|
+
}
|
|
479
|
+
if (typeof item.capturedAt !== "number" || !Number.isFinite(item.capturedAt)) {
|
|
480
|
+
return { ok: false, error: "evidence.capturedAt must be a number" };
|
|
481
|
+
}
|
|
482
|
+
items.push({
|
|
483
|
+
command: command.value,
|
|
484
|
+
exitCode: item.exitCode,
|
|
485
|
+
tree: tree.value,
|
|
486
|
+
capturedBy: item.capturedBy,
|
|
487
|
+
capturedAt: item.capturedAt,
|
|
488
|
+
...unknownFieldsOf(item, EVIDENCE_KEYS),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, value: items };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Read a handoff leniently: the reader of a stored handoff, which must not refuse a note because
|
|
495
|
+
* a newer Hypher wrote a field, a longer list, or a longer line than this code would. For any
|
|
496
|
+
* input `parseHandoffProposal` accepts, the known fields come out equal. A `schemaVersion` above 1
|
|
497
|
+
* parses but is flagged `readOnly`: the meaning may have changed, so a writer must not save over
|
|
498
|
+
* it. A missing, non-integer, or below-1 `schemaVersion` is an error.
|
|
499
|
+
*/
|
|
500
|
+
export function parseHandoffProposalLenient(value) {
|
|
501
|
+
if (!isObject(value))
|
|
502
|
+
return { ok: false, error: "proposal must be an object" };
|
|
503
|
+
const schemaVersion = value.schemaVersion;
|
|
504
|
+
if (typeof schemaVersion !== "number" || !Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
|
505
|
+
return { ok: false, error: "proposal.schemaVersion must be a whole number, 1 or higher" };
|
|
506
|
+
}
|
|
507
|
+
const goal = readText(value.goal, "goal");
|
|
508
|
+
if (!goal.ok)
|
|
509
|
+
return goal;
|
|
510
|
+
const constraints = readTextList(value.constraints, "constraints");
|
|
511
|
+
if (!constraints.ok)
|
|
512
|
+
return constraints;
|
|
513
|
+
const completed = readTextList(value.completed, "completed");
|
|
514
|
+
if (!completed.ok)
|
|
515
|
+
return completed;
|
|
516
|
+
const unverified = readTextList(value.unverified, "unverified");
|
|
517
|
+
if (!unverified.ok)
|
|
518
|
+
return unverified;
|
|
519
|
+
const blockers = readTextList(value.blockers, "blockers");
|
|
520
|
+
if (!blockers.ok)
|
|
521
|
+
return blockers;
|
|
522
|
+
const nextAction = readText(value.nextAction, "nextAction");
|
|
523
|
+
if (!nextAction.ok)
|
|
524
|
+
return nextAction;
|
|
525
|
+
if (!Array.isArray(value.decisions))
|
|
526
|
+
return { ok: false, error: "decisions must be an array" };
|
|
527
|
+
const decisions = [];
|
|
528
|
+
for (const item of value.decisions) {
|
|
529
|
+
if (!isObject(item))
|
|
530
|
+
return { ok: false, error: "decisions must be objects" };
|
|
531
|
+
const decision = readText(item.decision, "decisions.decision");
|
|
532
|
+
if (!decision.ok)
|
|
533
|
+
return decision;
|
|
534
|
+
const reason = readText(item.reason, "decisions.reason");
|
|
535
|
+
if (!reason.ok)
|
|
536
|
+
return reason;
|
|
537
|
+
const entry = { decision: decision.value, reason: reason.value };
|
|
538
|
+
if (item.status !== undefined) {
|
|
539
|
+
if (item.status !== "reported" && item.status !== "approved")
|
|
540
|
+
return { ok: false, error: "decisions.status is invalid" };
|
|
541
|
+
entry.status = item.status;
|
|
542
|
+
}
|
|
543
|
+
if (item.sourceRefs !== undefined) {
|
|
544
|
+
const sourceRefs = readTextList(item.sourceRefs, "decisions.sourceRefs");
|
|
545
|
+
if (!sourceRefs.ok)
|
|
546
|
+
return sourceRefs;
|
|
547
|
+
entry.sourceRefs = sourceRefs.value;
|
|
548
|
+
}
|
|
549
|
+
if (item.supersedes !== undefined) {
|
|
550
|
+
const supersedes = readText(item.supersedes, "decisions.supersedes");
|
|
551
|
+
if (!supersedes.ok)
|
|
552
|
+
return supersedes;
|
|
553
|
+
entry.supersedes = supersedes.value;
|
|
554
|
+
}
|
|
555
|
+
decisions.push({ ...entry, ...unknownFieldsOf(item, DECISION_KEYS) });
|
|
556
|
+
}
|
|
557
|
+
if (!Array.isArray(value.sources))
|
|
558
|
+
return { ok: false, error: "sources must be an array" };
|
|
559
|
+
const sources = [];
|
|
560
|
+
for (const item of value.sources) {
|
|
561
|
+
if (!isObject(item))
|
|
562
|
+
return { ok: false, error: "sources must be objects" };
|
|
563
|
+
const ref = readText(item.ref, "sources.ref");
|
|
564
|
+
if (!ref.ok)
|
|
565
|
+
return ref;
|
|
566
|
+
const source = { ref: ref.value };
|
|
567
|
+
if (item.label !== undefined) {
|
|
568
|
+
const label = readText(item.label, "sources.label");
|
|
569
|
+
if (!label.ok)
|
|
570
|
+
return label;
|
|
571
|
+
source.label = label.value;
|
|
572
|
+
}
|
|
573
|
+
if (item.kind !== undefined) {
|
|
574
|
+
if (item.kind !== "agent_report" && item.kind !== "capture" && item.kind !== "agent_event") {
|
|
575
|
+
return { ok: false, error: "sources.kind is invalid" };
|
|
576
|
+
}
|
|
577
|
+
source.kind = item.kind;
|
|
578
|
+
}
|
|
579
|
+
if (item.sourceId !== undefined) {
|
|
580
|
+
const sourceId = readText(item.sourceId, "sources.sourceId");
|
|
581
|
+
if (!sourceId.ok)
|
|
582
|
+
return sourceId;
|
|
583
|
+
source.sourceId = sourceId.value;
|
|
584
|
+
}
|
|
585
|
+
sources.push({ ...source, ...unknownFieldsOf(item, SOURCE_KEYS) });
|
|
586
|
+
}
|
|
587
|
+
const repo = readRepo(value.repo);
|
|
588
|
+
if (!repo.ok)
|
|
589
|
+
return repo;
|
|
590
|
+
let evidence;
|
|
591
|
+
if (value.evidence !== undefined) {
|
|
592
|
+
const read = readEvidence(value.evidence);
|
|
593
|
+
if (!read.ok)
|
|
594
|
+
return read;
|
|
595
|
+
evidence = read.value;
|
|
596
|
+
}
|
|
597
|
+
const proposal = {
|
|
598
|
+
schemaVersion,
|
|
599
|
+
goal: goal.value,
|
|
600
|
+
constraints: constraints.value,
|
|
601
|
+
decisions,
|
|
602
|
+
completed: completed.value,
|
|
603
|
+
unverified: unverified.value,
|
|
604
|
+
blockers: blockers.value,
|
|
605
|
+
nextAction: nextAction.value,
|
|
606
|
+
sources,
|
|
607
|
+
repo: repo.value,
|
|
608
|
+
...(evidence !== undefined ? { evidence } : {}),
|
|
609
|
+
...unknownFieldsOf(value, PROPOSAL_KEYS),
|
|
610
|
+
};
|
|
611
|
+
return { ok: true, value: proposal, readOnly: schemaVersion > HANDOFF_PROPOSAL_SCHEMA_VERSION };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* A read handoff as version 1 with only the fields this code knows: unknown fields are dropped at
|
|
615
|
+
* the top level and on each decision, source, and evidence item. Null above version 1, whose known
|
|
616
|
+
* fields may no longer mean what they meant. What a writer sends to be checked by the strict parser.
|
|
617
|
+
*/
|
|
618
|
+
export function knownFieldsV1(read) {
|
|
619
|
+
if (read.schemaVersion !== HANDOFF_PROPOSAL_SCHEMA_VERSION)
|
|
620
|
+
return null;
|
|
621
|
+
return {
|
|
622
|
+
schemaVersion: HANDOFF_PROPOSAL_SCHEMA_VERSION,
|
|
623
|
+
goal: read.goal,
|
|
624
|
+
constraints: read.constraints,
|
|
625
|
+
decisions: read.decisions.map((item) => knownFieldsOnly(item, DECISION_KEYS)),
|
|
626
|
+
completed: read.completed,
|
|
627
|
+
unverified: read.unverified,
|
|
628
|
+
blockers: read.blockers,
|
|
629
|
+
nextAction: read.nextAction,
|
|
630
|
+
sources: read.sources.map((item) => knownFieldsOnly(item, SOURCE_KEYS)),
|
|
631
|
+
repo: knownFieldsOnly(read.repo, REPO_KEYS),
|
|
632
|
+
...(read.evidence ? { evidence: read.evidence.map((item) => knownFieldsOnly(item, EVIDENCE_KEYS)) } : {}),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Put back, unchanged, the unknown fields `carrier` held: the top-level ones, and those of each
|
|
637
|
+
* decision, source, and evidence item. `known` must be what a parse of `knownFieldsV1(carrier)`
|
|
638
|
+
* returns, or any list built from it, with the same items in the same order: the fields go back
|
|
639
|
+
* by position. Nothing is invented or edited.
|
|
640
|
+
*/
|
|
641
|
+
export function reattachUnknownFields(known, carrier) {
|
|
642
|
+
const back = (items, from, keys) => items.map((item, index) => {
|
|
643
|
+
const owner = from?.[index];
|
|
644
|
+
return isObject(owner) ? { ...item, ...unknownFieldsOf(owner, keys) } : item;
|
|
645
|
+
});
|
|
646
|
+
return {
|
|
647
|
+
...known,
|
|
648
|
+
...unknownFieldsOf(carrier, PROPOSAL_KEYS),
|
|
649
|
+
decisions: back(known.decisions, carrier.decisions, DECISION_KEYS),
|
|
650
|
+
sources: back(known.sources, carrier.sources, SOURCE_KEYS),
|
|
651
|
+
...(known.evidence ? { evidence: back(known.evidence, carrier.evidence, EVIDENCE_KEYS) } : {}),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
412
654
|
export function renderHandoffPacket(proposal) {
|
|
413
655
|
const lines = [
|
|
414
656
|
`Goal: ${proposal.goal}`,
|
|
@@ -613,6 +855,30 @@ export function planHandoffDelivery(input) {
|
|
|
613
855
|
receipt,
|
|
614
856
|
};
|
|
615
857
|
}
|
|
858
|
+
export function headFromRow(projectId, row) {
|
|
859
|
+
if (!row || typeof row.revision !== "number")
|
|
860
|
+
return { kind: "none" };
|
|
861
|
+
const unreadable = (error) => ({ kind: "unreadable", revision: row.revision, error });
|
|
862
|
+
if (!row.proposal || !row.idempotencyKey || !row.sourceAgent)
|
|
863
|
+
return unreadable("the stored row is incomplete");
|
|
864
|
+
const parsed = parseHandoffProposalLenient(row.proposal);
|
|
865
|
+
if (!parsed.ok)
|
|
866
|
+
return unreadable(parsed.error);
|
|
867
|
+
const proposal = knownFieldsV1(parsed.value);
|
|
868
|
+
if (!proposal)
|
|
869
|
+
return unreadable(`it is format version ${parsed.value.schemaVersion}, newer than this server reads`);
|
|
870
|
+
return {
|
|
871
|
+
kind: "head",
|
|
872
|
+
head: {
|
|
873
|
+
projectId,
|
|
874
|
+
revision: row.revision,
|
|
875
|
+
idempotencyKey: row.idempotencyKey,
|
|
876
|
+
proposal,
|
|
877
|
+
source: row.sourceAgent,
|
|
878
|
+
savedAt: row.generatedAt,
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
}
|
|
616
882
|
export function readHandoffCommand(value) {
|
|
617
883
|
if (!isObject(value))
|
|
618
884
|
return { ok: true, command: null };
|