@litterthanlit/hypher-cli 0.2.0 → 0.3.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/src/session.mjs CHANGED
@@ -4,11 +4,11 @@
4
4
  */
5
5
  import { createClient, HypherRejected, HypherUnavailable } from "./api.mjs";
6
6
  import { readActiveDecisions, readWholeLedger } from "./ledger.mjs";
7
- import { linkFor } from "./config.mjs";
7
+ import { CLOUD_NOT_CONNECTED_LINE, cloudUnlinkedLine, findLink, inCloud } from "./cloud.mjs";
8
8
  import { canonicalJson, sha256 } from "./fsutil.mjs";
9
9
  import { flushOutbox } from "./outbox.mjs";
10
10
  import { handoffTreeOf, takeSnapshot } from "./snapshot.mjs";
11
- import { readWorktreeState, updateWorktreeState, withBranchState } from "./state.mjs";
11
+ import { branchState, readWorktreeState, updateWorktreeState, withBranchState } from "./state.mjs";
12
12
  import { inCodexSandbox, SANDBOX_LINE } from "./env.mjs";
13
13
  import { findSecretInValue } from "./secrets.mjs";
14
14
  import { keepHandoffTree, truthCheck } from "../vendor/hypher-git.mjs";
@@ -61,22 +61,35 @@ function checkpointFrom(body) {
61
61
  };
62
62
  }
63
63
 
64
+ /** SessionStart sources after which the same work goes on: `compact` and `clear` do not move the seal baseline. */
65
+ export const KEEPS_SEAL_BASELINE = new Set(["compact", "clear"]);
66
+
64
67
  /**
65
68
  * Load the handoff for the repository at cwd, check it against the working
66
69
  * tree, record a prepared receipt, and render the note. Never throws for a
67
70
  * missing link, credential, or network; returns a status and one line instead.
68
71
  * `full` prints every item, uncut; for a project whose decision ledger is on it also reads the ledger's lists.
69
- * @param {{ env: NodeJS.ProcessEnv, cwd: string, destination: string, sessionId?: string, now?: number, timeoutMs?: number, full?: boolean }} options
72
+ * `source` is the SessionStart hook's: for `compact` and `clear` the session goes on with the same work, so the
73
+ * seal baseline stays where it was and edits made before it still count at the next Stop. Anything else (or no
74
+ * source, as for `hypher resume` and the MCP tool) moves the baseline to the tree now.
75
+ * @param {{ env: NodeJS.ProcessEnv, cwd: string, destination: string, sessionId?: string, now?: number, timeoutMs?: number, full?: boolean, source?: string }} options
70
76
  */
71
- export async function resumeHere({ env, cwd, destination, sessionId, now = Date.now(), timeoutMs = 2_500, full = false }) {
77
+ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.now(), timeoutMs = 2_500, full = false, source }) {
72
78
  const snapshot = takeSnapshot(cwd);
73
79
  if (!snapshot) return { status: "not-git", note: "" };
74
80
  if (!snapshot.repository) {
75
81
  return { status: "no-remote", note: "Hypher: this repository has no GitHub remote, so Hypher has no note for it." };
76
82
  }
77
- const link = linkFor(snapshot.repository, env);
83
+ // In a cloud session a repository a human linked in Hypher is linked here without a click (cloud.mjs).
84
+ const found = await findLink(env, snapshot.repository, { force: true, now, timeoutMs });
85
+ const link = found.link;
78
86
  // A repository no human linked uploads nothing at all.
79
- if (!link) return { status: "unlinked", note: renderUnlinkedLine(snapshot.repository), snapshot };
87
+ if (!link) {
88
+ if (found.status === "not-connected") return { status: "not-connected", note: CLOUD_NOT_CONNECTED_LINE, snapshot };
89
+ if (found.status === "unavailable") return { status: "unavailable", note: HYPHER_UNAVAILABLE_LINE, snapshot };
90
+ if (found.status === "rejected") return { status: "rejected", note: `Hypher: ${found.error}`, snapshot };
91
+ return { status: "unlinked", note: inCloud(env) ? cloudUnlinkedLine(snapshot.repository) : renderUnlinkedLine(snapshot.repository), snapshot };
92
+ }
80
93
  const client = createClient({ env, timeoutMs });
81
94
  if (!client.apiKey) {
82
95
  return inCodexSandbox(env)
@@ -84,6 +97,9 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
84
97
  : { status: "not-connected", note: NOT_CONNECTED_LINE, snapshot };
85
98
  }
86
99
  const unavailableLine = inCodexSandbox(env) ? SANDBOX_LINE : HYPHER_UNAVAILABLE_LINE;
100
+ // The tree the next Stop compares against: the one already recorded for this branch after a compaction or a
101
+ // /clear (unsealed edits stay unsealed), the tree now at a start or a resume, or when none was recorded.
102
+ const sealBaseline = (state) => (KEEPS_SEAL_BASELINE.has(source) ? branchState(state, snapshot.branch).sealTree : undefined) ?? snapshot.tree;
87
103
 
88
104
  let prepared;
89
105
  let checkpoint = null;
@@ -118,9 +134,9 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
118
134
  // A project with no handoff saves its first one the v1 way, in every mode: no ledger flag.
119
135
  const { ledger: _ledger, ...rest } = state;
120
136
  return withBranchState({ ...rest, ...base, baseRevision: 0, proposal: null, receiptId: null, receiptRevision: null },
121
- snapshot.branch, { sealTree: snapshot.tree });
137
+ snapshot.branch, { sealTree: sealBaseline(state) });
122
138
  });
123
- return { status: "no-handoff", note: renderNoHandoffNote({ repository: snapshot.repository, projectName: link.projectName, now, checkpoint: shown }), snapshot, checkpoint: shown };
139
+ return { status: "no-handoff", note: renderNoHandoffNote({ repository: snapshot.repository, projectName: link.projectName, now, checkpoint: shown, checkpointClock: true }), snapshot, checkpoint: shown };
124
140
  }
125
141
  return { status: "rejected", note: `Hypher: ${prepared?.error ?? "could not load the handoff"}. Run \`hypher doctor\`.`, snapshot };
126
142
  }
@@ -148,6 +164,7 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
148
164
  // Each reported check says whether its tree is the tree now.
149
165
  currentTree: verdict?.current?.tree ?? snapshot.tree,
150
166
  checkpoint: shownCheckpoint,
167
+ checkpointClock: true,
151
168
  acknowledge: ACK_HINT,
152
169
  notice,
153
170
  moreHint: MORE_HINT,
@@ -170,7 +187,7 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
170
187
  // A later save moves baseRevision; the receipt stays tied to the revision that was read.
171
188
  receiptRevision: prepared.revision,
172
189
  ...(ledger ? { ledger } : {}),
173
- }, snapshot.branch, { sealTree: snapshot.tree });
190
+ }, snapshot.branch, { sealTree: sealBaseline(state) });
174
191
  });
175
192
  const wholeLedger = full && ledgerNote
176
193
  ? await readWholeLedger(client, { projectId: link.projectId, branch: snapshot.branch })
@@ -207,8 +224,8 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
207
224
  const snapshot = takeSnapshot(cwd);
208
225
  if (!snapshot) return { ok: false, code: "not-git", message: "Run this inside a git repository." };
209
226
  if (!snapshot.repository) return { ok: false, code: "no-remote", message: "This repository has no GitHub remote, so Hypher cannot save for it." };
210
- const link = linkFor(snapshot.repository, env);
211
- if (!link) return { ok: false, code: "unlinked", message: renderUnlinkedLine(snapshot.repository) };
227
+ const { link } = await findLink(env, snapshot.repository, { now, timeoutMs: 10_000 });
228
+ if (!link) return { ok: false, code: "unlinked", message: inCloud(env) ? cloudUnlinkedLine(snapshot.repository) : renderUnlinkedLine(snapshot.repository) };
212
229
 
213
230
  // Agent text is scanned before anything is built or sent.
214
231
  const secret = findSecretInValue(seal);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * How often a PostToolUse hook may record a checkpoint (M3 slice 3). One small file per working directory
3
+ * holds the time of the last checkpoint for each kind of tool, so a call that is too soon answers from that
4
+ * file alone, with no git call. A file edit may record every 10 seconds, a command every 60, each counted
5
+ * on its own: a command does not wait for an edit, nor an edit for a command.
6
+ */
7
+ import path from "node:path";
8
+ import { paths } from "./env.mjs";
9
+ import { readJson, sha256, writeJson } from "./fsutil.mjs";
10
+
11
+ export const EDIT_WINDOW_MS = 10_000;
12
+ export const BASH_WINDOW_MS = 60_000;
13
+
14
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
15
+
16
+ /** "edit" for a tool that changes files, "bash" for a command, else null (no checkpoint). */
17
+ export function toolKind(toolName) {
18
+ if (typeof toolName !== "string") return null;
19
+ if (EDIT_TOOLS.has(toolName)) return "edit";
20
+ return toolName === "Bash" ? "bash" : null;
21
+ }
22
+
23
+ const WINDOWS = { edit: EDIT_WINDOW_MS, bash: BASH_WINDOW_MS };
24
+
25
+ function throttleFile(env, cwd) {
26
+ return path.join(paths(env).stateDir, "throttle", `${sha256(String(cwd)).slice(0, 20)}.json`);
27
+ }
28
+
29
+ /**
30
+ * True, and counted, when a checkpoint of this kind may be recorded now; false when the last one is too
31
+ * recent. A last time in the future (a clock that moved back) does not block.
32
+ * @param {NodeJS.ProcessEnv} env
33
+ * @param {string} cwd
34
+ * @param {"edit" | "bash"} kind
35
+ * @param {number} now
36
+ */
37
+ export function claimCheckpoint(env, cwd, kind, now) {
38
+ const file = throttleFile(env, cwd);
39
+ const saved = readJson(file, null);
40
+ const last = saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {};
41
+ const at = last[kind];
42
+ if (typeof at === "number" && now >= at && now - at < WINDOWS[kind]) return false;
43
+ // Counted before the git work, so two hooks started together do not both record.
44
+ writeJson(file, { ...last, [kind]: now }, { mode: 0o600 });
45
+ return true;
46
+ }
@@ -88,9 +88,23 @@ export function describeVerdict(verdict, maxFiles = MAX_FILES_SHOWN) {
88
88
  return `Tree: this checkout is ${noteText(verdict.current?.repository ?? "unknown", 200)}, not the repository the handoff recorded. ${STOP}`;
89
89
  }
90
90
  }
91
- export function describeCheckpoint(checkpoint, now, maxFiles = MAX_FILES_SHOWN) {
91
+ /**
92
+ * "14:22 UTC, 2026-11-02": when a checkpoint was captured, in UTC so the line reads the same on every machine.
93
+ * An unreadable time gives "", and the line goes without it.
94
+ */
95
+ export function formatClockUtc(at) {
96
+ if (!Number.isFinite(at))
97
+ return "";
98
+ const date = new Date(at);
99
+ if (Number.isNaN(date.getTime()))
100
+ return "";
101
+ const iso = date.toISOString();
102
+ return `${iso.slice(11, 16)} UTC, ${iso.slice(0, 10)}`;
103
+ }
104
+ export function describeCheckpoint(checkpoint, now, maxFiles = MAX_FILES_SHOWN, options = {}) {
92
105
  const files = checkpoint.changedFiles.length ? `changed: ${fileList(checkpoint.changedFiles, maxFiles)}` : "no uncommitted changes";
93
- return `Last checkpoint: ${formatAge(now - checkpoint.capturedAt)} ago from ${noteText(checkpoint.source, 40)} (${noteText(checkpoint.event, 40)}), not sealed · branch ${noteText(checkpoint.branch, 200)} · ${files}.`;
106
+ const clock = options.clock ? formatClockUtc(checkpoint.capturedAt) : "";
107
+ return `Last checkpoint: ${formatAge(now - checkpoint.capturedAt)} ago${clock ? ` (${clock})` : ""} from ${noteText(checkpoint.source, 40)} (${noteText(checkpoint.event, 40)}), not sealed · branch ${noteText(checkpoint.branch, 200)} · ${files}.`;
94
108
  }
95
109
  /** A code span that survives backticks inside the text. */
96
110
  function codeSpan(text) {
@@ -152,7 +166,7 @@ function sections(input, max, listLimit) {
152
166
  ...(proposal.evidence?.length
153
167
  ? [`Evidence: ${limit(proposal.evidence).map((item) => describeEvidence(item, input.now, input.currentTree, max)).join(" ")}${more(proposal.evidence)}`]
154
168
  : []),
155
- ...(input.checkpoint ? [describeCheckpoint(input.checkpoint, input.now)] : []),
169
+ ...(input.checkpoint ? [describeCheckpoint(input.checkpoint, input.now, undefined, { clock: input.checkpointClock })] : []),
156
170
  `Sources: ${limit(proposal.sources).map((source) => {
157
171
  const kind = source.kind === "capture" ? "project capture" : source.kind === "agent_event" ? "stored agent report" : "agent report";
158
172
  return `${noteText(source.label ? `${source.label}: ${source.ref}` : source.ref, max)} (${kind})`;
@@ -180,7 +194,7 @@ export function renderNoHandoffNote(input) {
180
194
  return frame([
181
195
  `Hypher · ${noteText(input.repository, 200)}${input.projectName ? ` · project ${noteText(input.projectName, 120)}` : ""} · no handoff saved yet`,
182
196
  NOTE_PREAMBLE,
183
- ...(input.checkpoint ? [describeCheckpoint(input.checkpoint, input.now)] : []),
197
+ ...(input.checkpoint ? [describeCheckpoint(input.checkpoint, input.now, undefined, { clock: input.checkpointClock })] : []),
184
198
  "Next: work as usual. When the tree changes, Hypher asks once for the decisions and the next action.",
185
199
  ]);
186
200
  }
@@ -79,7 +79,7 @@ function place(input, body, tier, withCheckpoint) {
79
79
  const preamble = out.indexOf(NOTE_PREAMBLE);
80
80
  out.splice(preamble >= 0 ? preamble + 1 : edge, 0, noteText(input.notice, 500));
81
81
  }
82
- const checkpoint = withCheckpoint && input.checkpoint ? describeCheckpoint(input.checkpoint, input.now, tier.files) : null;
82
+ const checkpoint = withCheckpoint && input.checkpoint ? describeCheckpoint(input.checkpoint, input.now, tier.files, { clock: true }) : null;
83
83
  const acknowledge = input.acknowledge ? ` Then acknowledge this handoff: ${input.acknowledge}.` : "";
84
84
  let nextAt = -1;
85
85
  for (let index = out.length - 1; index >= 0; index -= 1) {