@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litterthanlit/hypher-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Hypher bridge for coding agents: hooks, a local outbox, and a truth check against the working tree. Pilot, not yet recorded.",
5
5
  "type": "module",
6
6
  "bin": {
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.7";
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 { parseHandoffProposal } from "../vendor/structuredHandoff.mjs";
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
- const parsed = parseHandoffProposal(prepared.proposal, { allowPlaceholders: true });
108
- if (!parsed.ok) return { status: "rejected", note: "Hypher returned a handoff this CLI cannot read. Update with `hypher init`.", snapshot };
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 ? parseHandoffProposal(state.proposal, { allowPlaceholders: true }) : null;
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.
@@ -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)}`,
@@ -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
- const parsed = parseHandoffProposal(proposal);
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 };