@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.
@@ -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 };