@litterthanlit/hypher-cli 0.1.8 → 0.2.1

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.
@@ -5,11 +5,17 @@
5
5
  * once for a seal when the tree changed. Hooks never acknowledge, never block
6
6
  * twice in a row, and never block on Hypher being down. In a repository no
7
7
  * human linked they record and upload nothing.
8
+ *
9
+ * Claude Code alone (Codex parity is M4) also records a checkpoint after the note is read at SessionStart, and
10
+ * after a tool ran (PostToolUse: a file edit at most every 10 seconds, a command at most every 60), so a session
11
+ * killed in the middle of a turn still leaves one. PostToolUse reads only `cwd` and `tool_name` from its payload:
12
+ * the rest is the tool's input, which can be file contents, and is never looked at.
8
13
  */
9
14
  import { createClient } from "../api.mjs";
10
15
  import { linkFor } from "../config.mjs";
11
16
  import { enqueue, flushOutbox, shouldFlush } from "../outbox.mjs";
12
17
  import { resumeHere } from "../session.mjs";
18
+ import { claimCheckpoint, toolKind } from "../throttle.mjs";
13
19
  import { takeSnapshot } from "../snapshot.mjs";
14
20
  import { branchState, readWorktreeState, updateWorktreeState, withBranchState } from "../state.mjs";
15
21
  import { findSecret } from "../secrets.mjs";
@@ -48,18 +54,38 @@ export function recordCheckpoint(env, snapshot, event, now = Date.now(), agent =
48
54
  }, { env, now });
49
55
  }
50
56
 
51
- function sealRequest(firstHandoff) {
57
+ /**
58
+ * What the Stop hook asks for. The words are the same for every project, except that when the last resume found
59
+ * the decision ledger on, IDs are named: a decision is replaced by its ID, and the lists are said to be added to.
60
+ */
61
+ function sealRequest(firstHandoff, ledgerOn = false) {
52
62
  const goal = firstHandoff ? ' --goal "<the goal>"' : "";
63
+ const pairs = ledgerOn
64
+ ? "Add one --decision/--why pair per changed decision (--replaces d-3 names the earlier decision it replaces, by the ID in the note), or none if no decision changed; --next is always needed. What you list is added to the ledger, never replaced: close finished work by listing it under completed (--completed \"…\"). Report only checks you ran (evidence, or --evidence \"<command>=<exit code>\")."
65
+ : "Add one --decision/--why pair per changed decision (--replaces D1 names one it replaces), or none if no decision changed; --next is always needed. Report only checks you ran (evidence, or --evidence \"<command>=<exit code>\").";
53
66
  return [
54
67
  "Hypher: the working tree changed since the last handoff, and no one saved why.",
55
68
  "Before you stop, record the decisions that changed, the next action for the next agent, and the checks you ran with their exit codes:",
56
69
  `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
70
  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 \"…\").",
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. Report only checks you ran (evidence, or --evidence \"<command>=<exit code>\").",
71
+ pairs,
59
72
  "It sends only your text, file names, and commit identity. Hypher asks once; if you cannot save now, say so and stop.",
60
73
  ].join(" ");
61
74
  }
62
75
 
76
+ /** The resume statuses whose one line is also shown to the person (`systemMessage`), not only given to the model. */
77
+ const PERSON_SEES = new Set(["unavailable", "not-connected", "unlinked"]);
78
+
79
+ /** The SessionStart checkpoint, from the snapshot the read already took. It never fails a session start. */
80
+ function recordStartCheckpoint(env, snapshot, agent) {
81
+ try {
82
+ if (!snapshot?.repository || !snapshot.tree || !linkFor(snapshot.repository, env)) return;
83
+ recordCheckpoint(env, snapshot, "SessionStart", Date.now(), agent);
84
+ } catch {
85
+ // The note is already read; a checkpoint that could not be written is not worth a failed start.
86
+ }
87
+ }
88
+
63
89
  async function sessionStart(agent, env, input) {
64
90
  const result = await resumeHere({
65
91
  env,
@@ -69,7 +95,12 @@ async function sessionStart(agent, env, input) {
69
95
  timeoutMs: HOOK_TIMEOUT_MS,
70
96
  });
71
97
  if (result.status === "not-git" || !result.note) return null;
72
- return { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.note } };
98
+ // After the note is read and rendered, so the note never names its own checkpoint. Queued, not uploaded:
99
+ // the next session on this computer sends it.
100
+ if (agent === "claude-code") recordStartCheckpoint(env, result.snapshot, agent);
101
+ const output = { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.note } };
102
+ // The person sees these three too: the model alone may never mention that Hypher is not there.
103
+ return PERSON_SEES.has(result.status) ? { systemMessage: result.note, ...output } : output;
73
104
  }
74
105
 
75
106
  async function stop(agent, env, input, now = Date.now()) {
@@ -96,7 +127,23 @@ async function stop(agent, env, input, now = Date.now()) {
96
127
  updateWorktreeState(env, snapshot.root, (current) => withBranchState(current, snapshot.branch, {
97
128
  askedAt: now, askedTree: snapshot.tree, blockedLast: true,
98
129
  }));
99
- return { decision: "block", reason: sealRequest(!state.proposal) };
130
+ return { decision: "block", reason: sealRequest(!state.proposal, state.ledger?.on === true) };
131
+ }
132
+
133
+ /**
134
+ * A tool just ran. Only Claude Code has this hook. The payload carries the tool's input, which can be file
135
+ * contents, so nothing but `cwd` and `tool_name` is read from it. A call inside its window answers from one
136
+ * small file, with no git call. Queued, not uploaded: a laptop's next session sends it.
137
+ */
138
+ async function postToolUse(agent, env, input, now = Date.now()) {
139
+ if (agent !== "claude-code") return null;
140
+ const kind = toolKind(input.tool_name);
141
+ if (!kind) return null;
142
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
143
+ if (!claimCheckpoint(env, cwd, kind, now)) return null;
144
+ const found = linkedSnapshot(env, cwd);
145
+ if (found) recordCheckpoint(env, found.snapshot, "PostToolUse", now, agent);
146
+ return null;
100
147
  }
101
148
 
102
149
  async function checkpointAndUpload(agent, env, input, event, now = Date.now()) {
@@ -122,6 +169,8 @@ export async function handleAgentHook(agent, event, input, env) {
122
169
  return await sessionStart(agent, env, input);
123
170
  case "Stop":
124
171
  return await stop(agent, env, input);
172
+ case "PostToolUse":
173
+ return await postToolUse(agent, env, input);
125
174
  case "PreCompact":
126
175
  case "SessionEnd":
127
176
  return await checkpointAndUpload(agent, env, input, event);
package/src/ledger.mjs ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Reading the decision ledger (M2 slice 6): `GET /api/cli/ledger`, the one route that lists every
3
+ * item, dispute, and workstream of a project whose ledger is on. `hypher resume --full` prints what it
4
+ * returns, and `seal` reads the active decisions once when a decision is replaced by its words.
5
+ * Only a project in mode `on` ever reaches this; for any other the route says the ledger is not on.
6
+ */
7
+ import { HypherUnavailable } from "./api.mjs";
8
+ import { noteText } from "../vendor/handoffNote.mjs";
9
+
10
+ export const LEDGER_PAGE_LIMIT = 100;
11
+ /** Pages read for one list before `--full` stops and says so. A page is 100 rows. */
12
+ export const MAX_FULL_PAGES = 20;
13
+
14
+ /** The route for one page. `projectId` is the one a human linked; `branch` puts the reader's workstream first. */
15
+ export function ledgerRoute({ projectId, branch, section, kind, cursor, limit = LEDGER_PAGE_LIMIT }) {
16
+ const params = new URLSearchParams();
17
+ params.set("section", section);
18
+ if (kind) params.set("kind", kind);
19
+ params.set("limit", String(limit));
20
+ if (cursor) params.set("cursor", cursor);
21
+ params.set("projectId", projectId);
22
+ if (branch) params.set("branch", branch);
23
+ return `/api/cli/ledger?${params.toString()}`;
24
+ }
25
+
26
+ function refusal(answer) {
27
+ const json = answer.json ?? {};
28
+ if (answer.status === 401) return "Hypher did not accept this computer's key. Run `hypher init` again.";
29
+ if (json.ok === true && json.ledgerOn === false) return json.message ?? "This project's decision ledger is not on.";
30
+ return typeof json.error === "string" ? json.error : `Hypher answered ${answer.status}`;
31
+ }
32
+
33
+ /**
34
+ * The active decisions, newest first, for finding the ID of the one a seal replaces by its words. One page of
35
+ * 100. Throws `HypherUnavailable` when Hypher cannot be reached.
36
+ * @returns {Promise<{ ok: true, items: Array<{ id: string, text: string }>, complete: boolean } | { ok: false, error: string }>}
37
+ */
38
+ export async function readActiveDecisions(client, { projectId, branch }) {
39
+ const answer = await client.send("GET", ledgerRoute({ projectId, branch, section: "items", kind: "decision" }));
40
+ const page = answer.json?.items;
41
+ if (!answer.ok || answer.json?.ok !== true || answer.json.ledgerOn !== true || !page || !Array.isArray(page.list)) {
42
+ return { ok: false, error: refusal(answer) };
43
+ }
44
+ return {
45
+ ok: true,
46
+ items: page.list.filter((item) => typeof item?.id === "string" && typeof item.text === "string").map((item) => ({ id: item.id, text: item.text })),
47
+ complete: page.next === null || page.next === undefined,
48
+ };
49
+ }
50
+
51
+ /** Every page of one list, newest first, until `next` is null or the cap. */
52
+ async function readList(client, { projectId, branch, section }) {
53
+ const rows = [];
54
+ let cursor;
55
+ let total = 0;
56
+ for (let page = 0; page < MAX_FULL_PAGES; page += 1) {
57
+ const answer = await client.send("GET", ledgerRoute({ projectId, branch, section, cursor }));
58
+ const body = answer.json;
59
+ const list = body?.[section];
60
+ if (!answer.ok || body?.ok !== true || body.ledgerOn !== true || !list || !Array.isArray(list.list)) {
61
+ return { ok: false, error: refusal(answer) };
62
+ }
63
+ rows.push(...list.list);
64
+ total = typeof list.total === "number" ? list.total : rows.length;
65
+ cursor = list.next ?? undefined;
66
+ if (!cursor) return { ok: true, rows, total, cut: false, revision: body.revision, branch: body.branch, counts: body.counts };
67
+ }
68
+ return { ok: true, rows, total, cut: true };
69
+ }
70
+
71
+ /**
72
+ * Everything the ledger holds for a project in mode `on`: the items, disputes, and workstreams, read in
73
+ * pages of 100 until each list ends (at most 20 pages a list). Never throws for Hypher being down; says so.
74
+ * @returns {Promise<{ ok: true, revision: number, branch: string, counts: object, items: any[], disputes: any[], workstreams: any[], cut: string[] } | { ok: false, error: string }>}
75
+ */
76
+ export async function readWholeLedger(client, { projectId, branch }) {
77
+ try {
78
+ const out = { ok: true, revision: 0, branch: branch ?? "", counts: {}, items: [], disputes: [], workstreams: [], cut: [] };
79
+ for (const section of ["items", "disputes", "workstreams"]) {
80
+ const list = await readList(client, { projectId, branch, section });
81
+ if (!list.ok) return list;
82
+ out[section] = list.rows;
83
+ if (list.cut) out.cut.push(section);
84
+ if (typeof list.revision === "number") {
85
+ out.revision = list.revision;
86
+ out.branch = list.branch ?? out.branch;
87
+ out.counts = list.counts ?? out.counts;
88
+ }
89
+ }
90
+ return out;
91
+ } catch (error) {
92
+ if (error instanceof HypherUnavailable) return { ok: false, error: "Hypher unavailable. Run `hypher doctor`." };
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ const line = (text, max = 400) => noteText(text, max);
98
+
99
+ function itemLine(item) {
100
+ const standing = item.status === "approved" ? "approved" : item.origin === "human" ? `${item.status}, human-sourced` : item.status;
101
+ const reason = item.reason ? `, because ${line(item.reason)}` : "";
102
+ const replaces = item.supersedes ? ` Replaces ${line(item.supersedes, 20)}.` : "";
103
+ const disputed = Array.isArray(item.disputedWith) && item.disputedWith.length ? ` Disputed with ${item.disputedWith.map((id) => line(id, 20)).join(", ")}.` : "";
104
+ return `- ${line(item.id, 20)} [${line(standing, 40)}] ${line(item.text)}${reason}${replaces}${disputed}`;
105
+ }
106
+
107
+ function workstreamLines(ws) {
108
+ const goal = ws.goal ?? (Array.isArray(ws.goals) && ws.goals.length ? ws.goals.map((claim) => claim.text).join(" | ") : "none recorded");
109
+ const next = Array.isArray(ws.nextActions) && ws.nextActions.length ? ws.nextActions.map((claim) => claim.text).join(" | ") : "none recorded";
110
+ const tasks = Array.isArray(ws.tasks) ? ws.tasks : [];
111
+ const blockers = Array.isArray(ws.blockers) ? ws.blockers : [];
112
+ const evidence = Array.isArray(ws.evidence) ? ws.evidence : [];
113
+ return [
114
+ `- ${line(ws.branch, 120)}${ws.current ? " (this branch)" : ""}: goal ${line(goal, 300)}; next ${line(next, 300)}; ${tasks.length} task${tasks.length === 1 ? "" : "s"}, ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}, ${evidence.length} check${evidence.length === 1 ? "" : "s"}`,
115
+ ...tasks.map((task) => ` - ${line(task.id, 20)} [${line(task.status, 20)}] ${line(task.text)}`),
116
+ ...blockers.map((blocker) => ` - ${line(blocker.id, 20)} [blocker] ${line(blocker.text)}`),
117
+ ...evidence.map((item) => ` - check ${JSON.stringify(line(item.command, 200))} exit ${item.exitCode}`),
118
+ ];
119
+ }
120
+
121
+ /** The lists `hypher resume --full` prints after the note: one line for each item, dispute, and workstream. */
122
+ export function describeWholeLedger(ledger) {
123
+ const out = [];
124
+ out.push(`Decisions and constraints (${ledger.items.length}, retired ones left out):`);
125
+ out.push(...(ledger.items.length ? ledger.items.map(itemLine) : ["- none"]));
126
+ out.push("", `Disputes (${ledger.disputes.length}):`);
127
+ out.push(...(ledger.disputes.length ? ledger.disputes.map((dispute) => `- ${line(dispute.subject, 40)}: ${line(dispute.ask, 700)}`) : ["- none"]));
128
+ out.push("", `Workstreams (${ledger.workstreams.length}):`);
129
+ out.push(...(ledger.workstreams.length ? ledger.workstreams.flatMap(workstreamLines) : ["- none"]));
130
+ if (ledger.cut.length) {
131
+ out.push("", `Hypher: stopped after ${MAX_FULL_PAGES} pages of ${ledger.cut.join(" and ")}. Name an ID, or ask for a list with the \`read_ledger\` tool.`);
132
+ }
133
+ return out.join("\n");
134
+ }
package/src/outbox.mjs CHANGED
@@ -8,7 +8,7 @@ import { mkdirSync, readdirSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { paths } from "./env.mjs";
11
- import { readJson, removeFile, writeJson } from "./fsutil.mjs";
11
+ import { readJson, removeFile, sha256, writeJson } from "./fsutil.mjs";
12
12
  import { HypherUnavailable } from "./api.mjs";
13
13
 
14
14
  export const FLUSH_AGE_MS = 5 * 60_000;
@@ -51,6 +51,41 @@ export function enqueue(input, { env = process.env, now = Date.now() } = {}) {
51
51
  return item;
52
52
  }
53
53
 
54
+ function uploadedFile(env, key) {
55
+ return path.join(paths(env).stateDir, "uploaded", `${sha256(String(key)).slice(0, 20)}.json`);
56
+ }
57
+
58
+ /** The newest checkpoint Hypher accepted for a key, kept so `hypher status` can say it was uploaded. Never throws. */
59
+ function rememberUploaded(env, item, now) {
60
+ try {
61
+ const file = uploadedFile(env, item.key);
62
+ const before = readJson(file, null);
63
+ if (typeof before?.body?.capturedAt === "number" && before.body.capturedAt > item.body?.capturedAt) return;
64
+ writeJson(file, { key: item.key, uploadedAt: now, body: item.body }, { mode: 0o600 });
65
+ } catch {
66
+ // Only a convenience for `hypher status`.
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The newest checkpoint on this computer for a repository and branch: waiting in the outbox, or the last one
72
+ * Hypher accepted. `null` when there is none.
73
+ * @returns {{ state: "queued" | "uploaded", body: any, queuedAt?: number, uploadedAt?: number } | null}
74
+ */
75
+ export function newestCheckpoint(env, repository, branch) {
76
+ const key = `${String(repository).toLowerCase()}#${branch}`;
77
+ const queued = listOutbox(env)
78
+ .filter((entry) => entry.item.kind === "checkpoint" && entry.item.key === key)
79
+ .map((entry) => ({ state: "queued", body: entry.item.body, queuedAt: entry.item.queuedAt }))
80
+ .sort((a, b) => (b.body?.capturedAt ?? 0) - (a.body?.capturedAt ?? 0))[0];
81
+ const saved = readJson(uploadedFile(env, key), null);
82
+ const uploaded = typeof saved?.body?.capturedAt === "number"
83
+ ? { state: "uploaded", body: saved.body, uploadedAt: saved.uploadedAt }
84
+ : null;
85
+ if (queued && (!uploaded || (queued.body?.capturedAt ?? 0) >= uploaded.body.capturedAt)) return queued;
86
+ return uploaded ?? queued ?? null;
87
+ }
88
+
54
89
  export function oldestAgeMs(env = process.env, now = Date.now()) {
55
90
  const [oldest] = listOutbox(env);
56
91
  return oldest ? now - oldest.item.queuedAt : 0;
@@ -75,6 +110,7 @@ export async function flushOutbox(client, { env = process.env, now = Date.now(),
75
110
  try {
76
111
  const response = await client.send("POST", "/api/cli/checkpoints", { body: item.body });
77
112
  if (response.ok) {
113
+ rememberUploaded(env, item, now);
78
114
  removeFile(file);
79
115
  sent += 1;
80
116
  continue;
package/src/session.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  * `hypher seal`, and (later) the MCP tools all come through here.
4
4
  */
5
5
  import { createClient, HypherRejected, HypherUnavailable } from "./api.mjs";
6
+ import { readActiveDecisions, readWholeLedger } from "./ledger.mjs";
6
7
  import { linkFor } from "./config.mjs";
7
8
  import { canonicalJson, sha256 } from "./fsutil.mjs";
8
9
  import { flushOutbox } from "./outbox.mjs";
@@ -19,9 +20,19 @@ import {
19
20
  } from "../vendor/handoffNote.mjs";
20
21
  import { buildSealProposal, CLI_UPDATE_COMMAND, readOnlyHandoffMessage } from "../vendor/handoffSeal.mjs";
21
22
  import { parseHandoffProposalLenient } from "../vendor/structuredHandoff.mjs";
23
+ import { composeLedgerNote } from "../vendor/ledgerNoteLocal.mjs";
24
+ import {
25
+ checkLedgerCall,
26
+ describeLedgerSave,
27
+ isUnknownToolError,
28
+ LEDGER_NOT_ON_CODE,
29
+ planLedgerSeal,
30
+ } from "../vendor/ledgerSeal.mjs";
22
31
 
23
32
  // The MCP tool first: in an agent's shell `hypher` may be off PATH or inside Codex's sandbox.
24
33
  export const ACK_HINT = "call the `acknowledge` tool of the `hypher` MCP server, or run `hypher ack`";
34
+ /** What a cut list in the note says to do: `--full` prints the rest (mismatch 8 of the M2 design). */
35
+ export const MORE_HINT = "run `hypher resume --full`";
25
36
  const NOT_CONNECTED_LINE = "Hypher is not connected on this computer; starting without it. Run `hypher init` in a terminal.";
26
37
 
27
38
  /** Said at the top of the note when the handoff is newer than this copy of Hypher can save over. */
@@ -54,9 +65,10 @@ function checkpointFrom(body) {
54
65
  * Load the handoff for the repository at cwd, check it against the working
55
66
  * tree, record a prepared receipt, and render the note. Never throws for a
56
67
  * missing link, credential, or network; returns a status and one line instead.
57
- * @param {{ env: NodeJS.ProcessEnv, cwd: string, destination: string, sessionId?: string, now?: number, timeoutMs?: number }} options
68
+ * `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
58
70
  */
59
- export async function resumeHere({ env, cwd, destination, sessionId, now = Date.now(), timeoutMs = 2_500 }) {
71
+ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.now(), timeoutMs = 2_500, full = false }) {
60
72
  const snapshot = takeSnapshot(cwd);
61
73
  if (!snapshot) return { status: "not-git", note: "" };
62
74
  if (!snapshot.repository) {
@@ -102,8 +114,12 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
102
114
  if (prepared?.ok !== true) {
103
115
  if (prepared?.code === "no-handoff") {
104
116
  const shown = checkpoint && checkpoint.branch === snapshot.branch ? checkpoint : null;
105
- updateWorktreeState(env, snapshot.root, (state) => withBranchState({ ...state, ...base, baseRevision: 0, proposal: null, receiptId: null, receiptRevision: null },
106
- snapshot.branch, { sealTree: snapshot.tree }));
117
+ updateWorktreeState(env, snapshot.root, (state) => {
118
+ // A project with no handoff saves its first one the v1 way, in every mode: no ledger flag.
119
+ const { ledger: _ledger, ...rest } = state;
120
+ return withBranchState({ ...rest, ...base, baseRevision: 0, proposal: null, receiptId: null, receiptRevision: null },
121
+ snapshot.branch, { sealTree: snapshot.tree });
122
+ });
107
123
  return { status: "no-handoff", note: renderNoHandoffNote({ repository: snapshot.repository, projectName: link.projectName, now, checkpoint: shown }), snapshot, checkpoint: shown };
108
124
  }
109
125
  return { status: "rejected", note: `Hypher: ${prepared?.error ?? "could not load the handoff"}. Run \`hypher doctor\`.`, snapshot };
@@ -116,28 +132,49 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
116
132
  const verdict = truthCheck(snapshot.root, proposal.repo);
117
133
  const savedAt = typeof prepared.savedAt === "number" ? prepared.savedAt : undefined;
118
134
  const shownCheckpoint = checkpoint && (!savedAt || checkpoint.capturedAt > savedAt) ? checkpoint : null;
119
- const note = renderHandoffNote({
120
- proposal,
121
- revision: prepared.revision,
122
- now,
123
- from: prepared.sourceAgent,
124
- savedAt,
125
- verdict,
126
- // Each reported check says whether its tree is the tree now.
127
- currentTree: verdict?.current?.tree ?? snapshot.tree,
128
- checkpoint: shownCheckpoint,
129
- acknowledge: ACK_HINT,
130
- notice: parsed.readOnly ? readOnlyNotice(proposal.schemaVersion) : null,
135
+ // The server says a project's ledger is on by sending its note as a field. The signal is that field and
136
+ // never the words of the text: an installed CLI keeps what it was built to read.
137
+ const ledgerNote = typeof prepared.ledgerNote === "string" && prepared.ledgerNote !== "" ? prepared.ledgerNote : null;
138
+ const notice = parsed.readOnly ? readOnlyNotice(proposal.schemaVersion) : null;
139
+ const note = ledgerNote
140
+ ? composeLedgerNote({ serverNote: ledgerNote, verdict, checkpoint: shownCheckpoint, now, acknowledge: ACK_HINT, notice })
141
+ : renderHandoffNote({
142
+ proposal,
143
+ revision: prepared.revision,
144
+ now,
145
+ from: prepared.sourceAgent,
146
+ savedAt,
147
+ verdict,
148
+ // Each reported check says whether its tree is the tree now.
149
+ currentTree: verdict?.current?.tree ?? snapshot.tree,
150
+ checkpoint: shownCheckpoint,
151
+ acknowledge: ACK_HINT,
152
+ notice,
153
+ moreHint: MORE_HINT,
154
+ full,
155
+ });
156
+ // A resume of a branch with no workstream yet is shown the most recently active one, labeled by its branch
157
+ // (the window's `repo.branch`). The seal then carries that goal onto the new branch.
158
+ const ledger = ledgerNote
159
+ ? { on: true, labeledBranch: proposal.repo.branch !== snapshot.branch ? proposal.repo.branch : null, branch: snapshot.branch }
160
+ : null;
161
+ updateWorktreeState(env, snapshot.root, (state) => {
162
+ // Every resume decides the mode afresh: a flag from an earlier resume never outlives it.
163
+ const { ledger: _ledger, ...rest } = state;
164
+ return withBranchState({
165
+ ...rest,
166
+ ...base,
167
+ baseRevision: prepared.revision,
168
+ proposal,
169
+ receiptId: prepared.receiptId ?? null,
170
+ // A later save moves baseRevision; the receipt stays tied to the revision that was read.
171
+ receiptRevision: prepared.revision,
172
+ ...(ledger ? { ledger } : {}),
173
+ }, snapshot.branch, { sealTree: snapshot.tree });
131
174
  });
132
- updateWorktreeState(env, snapshot.root, (state) => withBranchState({
133
- ...state,
134
- ...base,
135
- baseRevision: prepared.revision,
136
- proposal,
137
- receiptId: prepared.receiptId ?? null,
138
- // A later save moves baseRevision; the receipt stays tied to the revision that was read.
139
- receiptRevision: prepared.revision,
140
- }, snapshot.branch, { sealTree: snapshot.tree }));
175
+ const wholeLedger = full && ledgerNote
176
+ ? await readWholeLedger(client, { projectId: link.projectId, branch: snapshot.branch })
177
+ : null;
141
178
  return {
142
179
  status: "ready",
143
180
  note,
@@ -147,6 +184,7 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
147
184
  projectId: link.projectId,
148
185
  checkpoint: shownCheckpoint,
149
186
  snapshot,
187
+ ...(wholeLedger ? { ledger: wholeLedger } : {}),
150
188
  };
151
189
  }
152
190
 
@@ -185,6 +223,22 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
185
223
  // A newer format than this copy can write: refused before anything is built or sent.
186
224
  if (prior?.ok && prior.readOnly) return { ok: false, code: "read-only", message: readOnlyHandoffMessage(prior.value.schemaVersion) };
187
225
  const iso = new Date(now).toISOString().slice(0, 16).replace("T", " ");
226
+ const sessionRef = `${agent} session on ${snapshot.branch}, ${iso} UTC`;
227
+
228
+ // A project whose ledger the last resume found on saves through it. Only a project on the old path ever
229
+ // reaches the v1 save below, so what it sends and prints is what it was.
230
+ if (state.ledger?.on === true) {
231
+ const outcome = await sealWithOps({ env, snapshot, link, state, prior, seal, agent, now, sessionRef });
232
+ if (outcome.result) return outcome.result;
233
+ // The server says the ledger is not on (or has no such tool): this seal is a v1 save. It cannot be one
234
+ // when an ops save already moved this session on, because the window kept here is then out of date.
235
+ if (state.ledger.windowStale) {
236
+ return { ok: false, code: "resume-first", message: "Hypher's decision ledger is not on for this project any more, and this session already saved through it, so the handoff kept here is out of date. Nothing was sent. Run `hypher resume`, then seal again." };
237
+ }
238
+ }
239
+ const ledgerOnly = ledgerOnlyInputMessage(seal);
240
+ if (ledgerOnly) return { ok: false, code: "not-ledger", message: ledgerOnly };
241
+
188
242
  // Checks are bound to the working-tree hash at this save. Without one, the handoff still
189
243
  // saves; the checks are left out and the agent is told.
190
244
  const lines = [];
@@ -197,7 +251,7 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
197
251
  prior: prior?.ok ? prior.value : null,
198
252
  seal: toSeal,
199
253
  repo: snapshot.currentRepo,
200
- sessionRef: `${agent} session on ${snapshot.branch}, ${iso} UTC`,
254
+ sessionRef,
201
255
  tree: snapshot.tree,
202
256
  now,
203
257
  });
@@ -252,12 +306,16 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
252
306
  }
253
307
  const tree = snapshot.tree;
254
308
  if (tree) keepHandoffTree(snapshot.root, { branch: snapshot.branch, tree, commit: snapshot.commit });
255
- updateWorktreeState(env, snapshot.root, (current) => withBranchState({
256
- ...current,
257
- baseRevision: result.revision,
258
- proposal: saved,
259
- agent,
260
- }, snapshot.branch, { sealTree: tree }));
309
+ updateWorktreeState(env, snapshot.root, (current) => {
310
+ // This was a v1 save, so the ledger flag (if the server turned out not to have one) goes.
311
+ const { ledger: _ledger, ...rest } = current;
312
+ return withBranchState({
313
+ ...rest,
314
+ baseRevision: result.revision,
315
+ proposal: saved,
316
+ agent,
317
+ }, snapshot.branch, { sealTree: tree });
318
+ });
261
319
  return {
262
320
  ok: true,
263
321
  code: result.code,
@@ -267,4 +325,123 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
267
325
  };
268
326
  }
269
327
 
328
+ /** The refusal for a seal that sends `open` or `ops` to a project the ledger is not on for. Nothing was sent. */
329
+ export function ledgerOnlyInputMessage(seal) {
330
+ // A client that fills every optional list with [] (or null) has sent nothing.
331
+ const given = (value) => value !== undefined && value !== null && !(Array.isArray(value) && value.length === 0);
332
+ const named = [given(seal.ops) ? "ops" : null, given(seal.open) ? "open" : null].filter(Boolean);
333
+ if (!named.length) return null;
334
+ const how = [
335
+ named.includes("open") ? "Put work that is still open under unverified." : null,
336
+ named.includes("ops") ? "To replace a decision, give the new one with --replaces." : null,
337
+ ].filter(Boolean).join(" ");
338
+ return `Refused: ${named.join(" and ")} apply only when the note says "Ledger: on", and this project saves handoffs the old way. Nothing was sent. ${how}`;
339
+ }
340
+
341
+ /**
342
+ * A save of a project whose ledger is on: one `record_ops` call built from the seal (the mapping and every check
343
+ * are `vendor/ledgerSeal.mjs`), based on the revision this session read. Returns `{ result }`, which is final
344
+ * (saved or refused), or `{ result: null }` when the server says the ledger is not on or has no such tool; the
345
+ * seal is then a v1 save. Hypher unavailable and a refusal by the ledger are final: nothing was saved, and
346
+ * Hypher never writes around the ledger.
347
+ */
348
+ async function sealWithOps({ env, snapshot, link, state, prior, seal, agent, now, sessionRef }) {
349
+ const refused = (code, message) => ({ result: { ok: false, code, message } });
350
+ const unavailable = () => (inCodexSandbox(env)
351
+ ? refused("sandboxed", SANDBOX_LINE)
352
+ : refused("unavailable", "Hypher unavailable; nothing was saved. Run `hypher doctor`, then seal again."));
353
+ const notConnected = () => (inCodexSandbox(env)
354
+ ? refused("sandboxed", SANDBOX_LINE)
355
+ : refused("not-connected", "This computer is not connected. Run `hypher init`."));
356
+
357
+ // Checks are bound to the working-tree hash at this save. Without one, the save still goes; the checks are left out and the agent is told.
358
+ const lines = [];
359
+ let toSeal = seal;
360
+ if (Array.isArray(seal.evidence) && seal.evidence.length && !snapshot.tree) {
361
+ toSeal = { ...seal, evidence: undefined };
362
+ lines.push("The checks were not recorded: Hypher could not read the working tree hash.");
363
+ }
364
+ // The branch this session resumed on had no workstream, so it was shown another one's; the goal is carried onto it.
365
+ const labeled = Boolean(state.ledger.labeledBranch) && (state.ledger.branch ?? snapshot.branch) === snapshot.branch;
366
+ const input = { seal: toSeal, prior: prior?.ok ? prior.value : null, labeled, sessionRef, tree: snapshot.tree, now };
367
+ const client = createClient({ env, timeoutMs: 10_000 });
368
+
369
+ let planned = planLedgerSeal(input);
370
+ if (!planned.ok && planned.needsDecisions) {
371
+ // A decision is replaced by its words: its ID is read once from the ledger.
372
+ if (!client.apiKey) return notConnected();
373
+ let decisions;
374
+ try {
375
+ decisions = await readActiveDecisions(client, { projectId: link.projectId, branch: snapshot.branch });
376
+ } catch (error) {
377
+ if (error instanceof HypherUnavailable) return unavailable();
378
+ throw error;
379
+ }
380
+ if (!decisions.ok) {
381
+ return refused("rejected", `Hypher could not list the decisions to find the one to replace (${decisions.error}). Nothing was sent. Name it by its ID (d-3) and seal again.`);
382
+ }
383
+ planned = planLedgerSeal({ ...input, decisions: decisions.items, decisionsComplete: decisions.complete });
384
+ }
385
+ if (!planned.ok) return refused("invalid", planned.error);
386
+ const checked = checkLedgerCall({
387
+ source: agent,
388
+ projectId: link.projectId,
389
+ baseRevision: state.baseRevision,
390
+ repo: snapshot.currentRepo,
391
+ ops: planned.ops,
392
+ labels: planned.labels,
393
+ });
394
+ if (!checked.ok) return refused("invalid", checked.error);
395
+ if (!client.apiKey) return notConnected();
396
+
397
+ // No idempotency key: the server makes one from the base, the branch, and the ops, and the checks and the session
398
+ // label are stamped to the minute, so a retry within the minute is the same call and replays.
399
+ let answer;
400
+ try {
401
+ answer = (await client.tool("record_ops", checked.args)).structured;
402
+ } catch (error) {
403
+ if (error instanceof HypherUnavailable) return unavailable();
404
+ if (error instanceof HypherRejected) {
405
+ if (isUnknownToolError(error.message, error.body?.rpcCode)) return { result: null };
406
+ return refused("rejected", error.message);
407
+ }
408
+ throw error;
409
+ }
410
+ if (answer?.code === LEDGER_NOT_ON_CODE) return { result: null };
411
+ if (answer?.ok !== true || typeof answer.revision !== "number") {
412
+ const error = String(answer?.error ?? "unknown error");
413
+ const behind = typeof answer?.headRevision === "number" && answer.headRevision < state.baseRevision
414
+ ? " Run `hypher resume`, then seal again."
415
+ : "";
416
+ return refused(answer?.code ?? "rejected", `Hypher refused the ledger update: ${error}${/nothing was (?:saved|recorded|written)/i.test(error) ? "" : " Nothing was saved."}${behind}`);
417
+ }
418
+
419
+ const tree = snapshot.tree;
420
+ if (tree) keepHandoffTree(snapshot.root, { branch: snapshot.branch, tree, commit: snapshot.commit });
421
+ // The window kept here is the one this session read; this save changed the ledger past it, so it is stale
422
+ // until the next resume. The branch now has its own workstream, so nothing is carried onto it again.
423
+ updateWorktreeState(env, snapshot.root, (current) => withBranchState({
424
+ ...current,
425
+ baseRevision: answer.revision,
426
+ agent,
427
+ ledger: { on: true, labeledBranch: null, branch: snapshot.branch, windowStale: true },
428
+ }, snapshot.branch, { sealTree: tree }));
429
+ return {
430
+ result: {
431
+ ok: true,
432
+ code: answer.code,
433
+ revision: answer.revision,
434
+ projectName: link.projectName,
435
+ message: describeLedgerSave({
436
+ answer,
437
+ labels: planned.labels,
438
+ base: state.baseRevision,
439
+ projectName: link.projectName ?? "this project",
440
+ branch: snapshot.branch,
441
+ extraLines: lines,
442
+ }),
443
+ },
444
+ };
445
+ }
446
+
270
447
  export { handoffTreeOf };
@@ -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
+ }