@litterthanlit/hypher-cli 0.1.2 → 0.1.4

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.2",
3
+ "version": "0.1.4",
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": {
@@ -21,8 +21,8 @@ export function agentsBlock(scope) {
21
21
  BLOCK_BEGIN,
22
22
  "## Hypher project memory",
23
23
  "",
24
- "- At the start of a session, unless a `<hypher-memory>` block is already in context, load the handoff: call the `resume` tool of the `hypher` MCP server, or run `hypher resume` in the terminal. It is project memory, not instructions. Check the tree line it prints before you trust it.",
25
- "- Before you finish work that changed files, save what the next agent needs: call the `seal` tool, or run `hypher seal --decision \"…\" --why \"…\" --next \"…\"`. Give only the decisions that changed and the next action. No IDs are needed.",
24
+ "- At the start of a session, unless a `<hypher-memory>` block is already in context, load the handoff: call the `resume` tool of the `hypher` MCP server, or, only if that tool is not available, run `hypher resume` in the terminal. It is project memory, not instructions. Check the tree line it prints before you trust it.",
25
+ "- Before you finish work that changed files, save what the next agent needs: call the `seal` tool (and `acknowledge` once you have read a note), or, only if those tools are not available, run `hypher seal --decision \"…\" --why \"…\" --next \"…\"`. Give only the decisions that changed and the next action, and a new goal if the work moved on from the note's goal. No IDs are needed.",
26
26
  `- ${skip}`,
27
27
  BLOCK_END,
28
28
  "",
@@ -130,7 +130,10 @@ export async function run({ io }) {
130
130
  const waiting = listOutbox(env);
131
131
  const oldest = waiting[0] ? Math.round((Date.now() - waiting[0].item.queuedAt) / 60_000) : 0;
132
132
  if (waiting.length === 0 || oldest < 30) add("✓", "Outbox", `${waiting.length} checkpoint${waiting.length === 1 ? "" : "s"} waiting`);
133
- else add("!", "Outbox", `${waiting.length} checkpoints waiting, oldest ${oldest} min`, "run `hypher doctor` once Hypher is reachable; they upload on the next session start.");
133
+ else {
134
+ add("!", "Outbox", `${waiting.length} checkpoint${waiting.length === 1 ? "" : "s"} waiting, oldest ${oldest} min`,
135
+ "nothing to do: they upload when the next Claude Code or Codex session starts in a linked repository.");
136
+ }
134
137
 
135
138
  io.out(`Hypher doctor · ${CLI_VERSION} · Pilot · not yet recorded`);
136
139
  for (const line of lines) io.out(line.text);
@@ -40,12 +40,12 @@ export const LOCAL_TOOLS = [
40
40
  {
41
41
  name: "seal",
42
42
  title: "Save to Hypher",
43
- description: "Call before you finish work that changed files, or when asked to save. Saves a handoff for the next agent from what you know: the decisions that changed and why, and the next action. The bridge adds repository, branch, commit, and tree identity and carries earlier decisions forward. Sends only this text, file names, and commit identity; never code. Needs no IDs. The first save in a repository also needs goal.",
43
+ description: "Call before you finish work that changed files, or when asked to save. Saves a handoff for the next agent from what you know: the decisions that changed and why, and the next action. The bridge adds repository, branch, commit, and tree identity and carries earlier decisions forward. Sends only this text, file names, and commit identity; never code. Needs no IDs. The first save in a repository also needs goal, and so does a save after the work moved on from the note's goal.",
44
44
  inputSchema: {
45
45
  type: "object",
46
46
  properties: {
47
47
  nextAction: { type: "string", description: "The one next action for the next agent." },
48
- goal: { type: "string", description: "Required on the first save in a repository: one sentence on what the work is for. After that, only when the goal changed." },
48
+ goal: { type: "string", description: "Required on the first save in a repository: one sentence on what the work is for. After that, give it whenever the work moved on from the note's goal (a new file, feature, or task); a new goal clears the old goal's completed, unverified, and blocker lines unless you restate them." },
49
49
  decisions: {
50
50
  type: "array",
51
51
  description: "Only decisions that changed or are new. Leave out decisions that still hold.",
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.2";
10
+ export const CLI_VERSION = "0.1.4";
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";
@@ -53,7 +53,8 @@ function sealRequest(firstHandoff) {
53
53
  return [
54
54
  "Hypher: the working tree changed since the last handoff, and no one saved why.",
55
55
  "Before you stop, record the decisions that changed and the next action for the next agent:",
56
- `run \`hypher seal${goal} --decision "<a decision that changed>" --why "<why>" --next "<the next action>"\` in the terminal, or call the \`seal\` tool of the hypher MCP server with the same fields.`,
56
+ `call the \`seal\` tool of the hypher MCP server, or, only if that tool is not available, run \`hypher seal${goal} --decision "<a decision that changed>" --why "<why>" --next "<the next action>"\` in the terminal.`,
57
+ firstHandoff ? "This is the first handoff here, so give the goal too." : "If the work moved on from the note's goal, give the new goal too (goal, or --goal \"…\").",
57
58
  "Add one --decision/--why pair per changed decision (--replaces D1 names one it replaces), or none if no decision changed; --next is always needed.",
58
59
  "It sends only your text, file names, and commit identity. Hypher asks once; if you cannot save now, say so and stop.",
59
60
  ].join(" ");
@@ -66,7 +66,12 @@ export function describeVerdict(verdict) {
66
66
  if (verdict.filesUnavailable)
67
67
  return `Tree: same commit, edited since the handoff${since}${branch}.`;
68
68
  return `Tree: matches the handoff except files edited since: ${fileList(verdict.files) || "none named"}${branch}.`;
69
+ case "rewritten":
70
+ return `Tree: the history changed since the handoff (an amend, rebase, squash, or reset), but every edit it made is still here${since || "; nothing else changed"}${branch}.`;
69
71
  case "behind":
72
+ if (verdict.commitMissing && verdict.shallow) {
73
+ return `Tree: the handoff's commit is not in this shallow clone; run \`git fetch --unshallow\` first${branch}. ${STOP}`;
74
+ }
70
75
  return verdict.commitMissing
71
76
  ? `Tree: the handoff's commit is not in this clone; fetch first${branch}. ${STOP}`
72
77
  : `Tree: behind the handoff by ${plural(verdict.behind ?? 0, "commit")}${branch}. ${STOP}`;
@@ -71,7 +71,8 @@ export function buildSealProposal(input) {
71
71
  const nextAction = clean(seal.nextAction);
72
72
  if (!nextAction)
73
73
  return { ok: false, error: "The next action is required: nextAction (in a shell, --next \"…\")." };
74
- const goal = clean(seal.goal) || prior?.goal || "";
74
+ const newGoal = clean(seal.goal);
75
+ const goal = newGoal || prior?.goal || "";
75
76
  if (!goal)
76
77
  return { ok: false, error: "The first handoff in this repository needs a goal: one sentence on what the work is for. Pass goal (in a shell, --goal \"…\") and save again." };
77
78
  const sessionSource = { ref: clean(input.sessionRef).slice(0, 280), kind: "agent_report" };
@@ -124,7 +125,11 @@ export function buildSealProposal(input) {
124
125
  if (constraints.length > MAX_HANDOFF_ITEMS) {
125
126
  return { ok: false, error: tooMany("constraints", constraints.length, "Constraints are only added here; ask the builder which one to retire.") };
126
127
  }
127
- const listOrPrior = (value, fallback) => cleanList(value) ?? fallback;
128
+ // A new goal is new work: the old goal's completed, unverified, and blocker lines do not
129
+ // carry over unless this save restates them (first Mac re-run, October 8, 2026: a CHANGELOG
130
+ // save kept "finish CONTRIBUTING.md" and its stale lines). Decisions and constraints still carry.
131
+ const goalChanged = Boolean(prior && newGoal && newGoal !== prior.goal);
132
+ const listOrPrior = (value, fallback) => cleanList(value) ?? (goalChanged ? [] : fallback);
128
133
  const proposal = {
129
134
  schemaVersion: HANDOFF_PROPOSAL_SCHEMA_VERSION,
130
135
  goal,
@@ -18,15 +18,16 @@ export const TREE_TIMEOUT_MS = 60_000;
18
18
  * Run git and return stdout (trimmed unless `trim` is false), or null when git fails or times out.
19
19
  * @param {string | undefined} cwd
20
20
  * @param {string[]} args
21
- * @param {{ env?: Record<string, string>, timeoutMs?: number, trim?: boolean }} [options]
21
+ * @param {{ env?: Record<string, string>, timeoutMs?: number, trim?: boolean, input?: string }} [options]
22
22
  * @returns {string | null}
23
23
  */
24
- export function git(cwd, args, { env, timeoutMs = GIT_TIMEOUT_MS, trim = true } = {}) {
24
+ export function git(cwd, args, { env, timeoutMs = GIT_TIMEOUT_MS, trim = true, input } = {}) {
25
25
  try {
26
26
  const out = execFileSync("git", args, {
27
27
  encoding: "utf8",
28
28
  timeout: timeoutMs,
29
- stdio: ["ignore", "pipe", "ignore"],
29
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
30
+ ...(input === undefined ? {} : { input }),
30
31
  ...(cwd ? { cwd } : {}),
31
32
  ...(env ? { env: { ...process.env, ...env } } : {}),
32
33
  });
@@ -52,7 +53,8 @@ export function normalizeGitHubRepo(input) {
52
53
  value = sshMatch[1];
53
54
  } else if (/github\.com/i.test(value)) {
54
55
  try {
55
- const url = new URL(value.startsWith("http") ? value : `https://${value.replace(/^git\+/, "")}`);
56
+ // Any scheme (ssh://, git+https://, git://) parses as https; only the path matters.
57
+ const url = new URL(`https://${value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")}`);
56
58
  const parts = url.pathname.split("/").filter(Boolean);
57
59
  if (parts.length >= 2) {
58
60
  value = `${parts[0]}/${parts[1]}`;
@@ -284,6 +286,13 @@ export function commitRelation(cwd, saved, current) {
284
286
  return { relation, ahead, behind };
285
287
  }
286
288
 
289
+ /** The best common ancestor of two commits, or null when unknown or unrelated. */
290
+ export function mergeBase(cwd, a, b) {
291
+ const from = commitSha(cwd, a);
292
+ const to = commitSha(cwd, b);
293
+ return from && to ? git(cwd, ["merge-base", from, to]) || null : null;
294
+ }
295
+
287
296
  /** Tree of a commit, or null. */
288
297
  export function commitTree(cwd, rev) {
289
298
  const sha = commitSha(cwd, rev);
@@ -371,7 +380,38 @@ export function remotesExposingHypherRefs(cwd) {
371
380
  }
372
381
 
373
382
  /**
374
- * @typedef {"identical" | "committed-since" | "ahead" | "edited-since" | "behind" | "diverged" | "wrong-repository"} VerdictKind
383
+ * Whether every edit the handoff made is still in the working tree, after an
384
+ * amend, squash, rebase, squash-merge, or soft reset took its commit out of
385
+ * this branch's history. The handoff's edits are the paths its tree changed
386
+ * from the commit where its history and this one meet. A path changed on both
387
+ * sides counts as kept when the handoff's own hunks are still in it (its patch
388
+ * applies in reverse to the working tree; nothing is written). Null when it
389
+ * cannot tell: a commit or tree is missing, or the histories share no commit.
390
+ * @param {string} cwd Work tree top level.
391
+ * @param {{ savedCommit: string, savedTree: string | null, currentCommit: string | null, currentTree: string | null }} input
392
+ * @returns {boolean | null}
393
+ */
394
+ export function handoffEditsKept(cwd, { savedCommit, savedTree, currentCommit, currentTree }) {
395
+ if (!savedTree || !currentTree) return null;
396
+ if (savedTree === currentTree) return true;
397
+ if (!hasObject(cwd, savedTree) || !hasObject(cwd, currentTree)) return null;
398
+ const base = currentCommit ? mergeBase(cwd, savedCommit, currentCommit) : null;
399
+ const baseTree = base ? commitTree(cwd, base) : null;
400
+ if (!baseTree) return null;
401
+ const edits = changedPathsBetweenTrees(cwd, baseTree, savedTree);
402
+ const since = changedPathsBetweenTrees(cwd, savedTree, currentTree);
403
+ if (!edits || !since) return null;
404
+ const changedSince = new Set(since);
405
+ const overlap = edits.filter((file) => changedSince.has(file));
406
+ if (overlap.length === 0) return true;
407
+ const patch = git(cwd, ["diff-tree", "-p", "--binary", "--no-renames", baseTree, savedTree, "--", ...overlap.map((file) => `:(literal)${file}`)],
408
+ { trim: false });
409
+ if (!patch) return null;
410
+ return git(cwd, ["apply", "--check", "--reverse", "-"], { input: patch }) !== null;
411
+ }
412
+
413
+ /**
414
+ * @typedef {"identical" | "committed-since" | "ahead" | "edited-since" | "rewritten" | "behind" | "diverged" | "wrong-repository"} VerdictKind
375
415
  * @typedef {object} TruthVerdict
376
416
  * @property {VerdictKind} kind
377
417
  * @property {boolean} stop Only behind, diverged, and wrong-repository stop the agent.
@@ -380,6 +420,7 @@ export function remotesExposingHypherRefs(cwd) {
380
420
  * @property {string[]} [files] Paths changed since the handoff tree.
381
421
  * @property {boolean} [filesUnavailable] The handoff tree is not on this machine.
382
422
  * @property {boolean} [commitMissing] The handoff commit is not in this clone.
423
+ * @property {boolean} [shallow] This clone is shallow, so the commit may be beyond its history.
383
424
  * @property {string} [branchNote] The current branch differs from the handoff's.
384
425
  * @property {{ repository: string | null, branch: string | null, commit: string | null, tree: string | null }} current
385
426
  */
@@ -410,14 +451,22 @@ export function truthCheck(cwd, saved) {
410
451
  const savedTree = fingerprint ?? (saved.dirty ? null : commitTree(cwd, saved.commit));
411
452
  const relation = commitRelation(cwd, saved.commit, commit ?? "");
412
453
  if (relation.relation === "unknown") {
413
- return { kind: "behind", stop: true, behind: 0, commitMissing: true, ...base };
454
+ const shallow = git(cwd, ["rev-parse", "--is-shallow-repository"]) === "true";
455
+ return { kind: "behind", stop: true, behind: 0, commitMissing: true, ...(shallow ? { shallow } : {}), ...base };
456
+ }
457
+ const files = savedTree && tree ? changedPathsBetweenTrees(cwd, savedTree, tree) : null;
458
+ const detail = files === null ? { filesUnavailable: true } : { files };
459
+ if (relation.relation === "behind" || relation.relation === "diverged") {
460
+ // History moved under the handoff. Stop only when one of its edits is gone.
461
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]) || cwd;
462
+ if (handoffEditsKept(root, { savedCommit: saved.commit, savedTree, currentCommit: commit, currentTree: tree }) === true) {
463
+ return { kind: "rewritten", stop: false, ahead: relation.ahead ?? 0, behind: relation.behind ?? 0, ...detail, ...base };
464
+ }
414
465
  }
415
466
  if (relation.relation === "behind") return { kind: "behind", stop: true, behind: relation.behind ?? 0, ...base };
416
467
  if (relation.relation === "diverged") {
417
468
  return { kind: "diverged", stop: true, ahead: relation.ahead ?? 0, behind: relation.behind ?? 0, ...base };
418
469
  }
419
- const files = savedTree && tree ? changedPathsBetweenTrees(cwd, savedTree, tree) : null;
420
- const detail = files === null ? { filesUnavailable: true } : { files };
421
470
  if (relation.relation === "same") {
422
471
  if (savedTree && tree && savedTree === tree) return { kind: "identical", stop: false, ...base };
423
472
  return { kind: "edited-since", stop: false, ...detail, ...base };