@litterthanlit/hypher-cli 0.1.7 → 0.2.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.
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/api.mjs +2 -1
- package/src/cli.mjs +4 -0
- package/src/commands/mcp.mjs +32 -3
- package/src/commands/resume.mjs +20 -5
- package/src/commands/seal.mjs +10 -1
- package/src/env.mjs +1 -1
- package/src/hooks/index.mjs +10 -3
- package/src/ledger.mjs +134 -0
- package/src/session.mjs +222 -36
- package/vendor/handoffNote.mjs +16 -8
- package/vendor/handoffSeal.mjs +30 -4
- package/vendor/ledgerNoteLocal.mjs +122 -0
- package/vendor/ledgerOps.mjs +387 -0
- package/vendor/ledgerSeal.mjs +283 -0
- package/vendor/structuredHandoff.mjs +266 -0
|
@@ -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 };
|