@litterthanlit/hypher-cli 0.2.1 → 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/README.md CHANGED
@@ -5,7 +5,7 @@ Pilot · not yet recorded. The bridge between coding agents and Hypher project m
5
5
  - `hypher init` connects this computer (one browser approval), installs a pinned copy under `~/.hypher/`, and adds Claude Code hooks and Codex hooks (Codex asks once to trust them: `/hooks`). It keeps M0a's `hypher` sign-in entry wherever it finds one.
6
6
  - `hypher doctor` checks each piece and prints the fix for anything red. When Hypher is unreachable, or something between this computer and it answers instead (a proxy's `403 Host not in allowlist`, an HTML page, an empty body), it names the host it tried and the status and content type that came back, never the body.
7
7
  - An answer that is not Hypher's own JSON counts as Hypher being unavailable, not as Hypher saying no: a queued checkpoint stays in the outbox until it uploads, and the session starts with "Hypher unavailable" (built, released with 0.2.1; not recorded). When Hypher is unavailable, not connected, or the repository is not linked, the `SessionStart` hook also puts that one line in front of you (`systemMessage`), not only in front of the model.
8
- - **When checkpoints are written (0.2.1; built and rehearsed, not recorded).** In Claude Code a checkpoint (identity, tree hash, file names, never contents) is queued when a session starts, after the note is read; after a file edit (`Edit`, `Write`, `MultiEdit`, `NotebookEdit`), at most every 10 seconds; after a command (`Bash`), at most every 60 seconds; and at turn end, before compaction, and at exit as before. So a session killed mid-turn leaves a checkpoint at most about 10 seconds behind its last edit. The new `PostToolUse` hook reads only `cwd` and the tool's name, never the tool's input, and does not touch the network; checkpoints upload when the next session starts here. `hypher status` prints the newest checkpoint of this worktree: its event, the clock time (local and UTC), its age, and whether it is still queued or uploaded. Re-run `npx @litterthanlit/hypher-cli@latest init` to add the hook to a 0.2.0 install. Codex is unchanged (no `PostToolUse` hook, no `SessionStart` checkpoint): that is M4.
8
+ - **When checkpoints are written (0.2.1; built and rehearsed, not recorded).** In Claude Code a checkpoint (identity, tree hash, file names, never contents) is queued when a session starts, after the note is read; after a file edit (`Edit`, `Write`, `MultiEdit`, `NotebookEdit`), at most every 10 seconds; after a command (`Bash`), at most every 60 seconds; and at turn end, before compaction, and at exit as before. So a session killed mid-turn leaves a checkpoint at most about 10 seconds behind its last edit. The new `PostToolUse` hook reads only `cwd` and the tool's name, never the tool's input, and, on a laptop, does not touch the network; checkpoints upload when the next session starts here (in a Claude Code cloud session they upload as they go: see Cloud sessions below). `hypher status` prints the newest checkpoint of this worktree: its event, the clock time (local and UTC), its age, and whether it is still queued or uploaded. Re-run `npx @litterthanlit/hypher-cli@latest init` to add the hook to a 0.2.0 install. Codex is unchanged (no `PostToolUse` hook, no `SessionStart` checkpoint): that is M4. The note's `Last checkpoint:` line gives the age and the capture time in UTC, for example `2 min ago (14:22 UTC, 2026-11-02)`, in every note the CLI prints (built, released with 0.3.0; the server's own text has no clock time). A `SessionStart` with source `compact` or `clear` keeps the seal baseline, so edits made before a compaction still make the next `Stop` ask for a seal; `startup` and `resume` move it to the tree now, as before (built, released with 0.3.0; Claude Code and Codex send the same source names, so both hooks do this).
9
9
  - `hypher resume` prints the note for this repository; `hypher seal` saves a handoff. `hypher resume --full` prints every item the note cut ("N more"); with `--json` it adds the whole decision ledger under `ledger`.
10
10
  - `hypher seal --evidence "<command>=<exit code>"` (repeatable, split at the last `=`) reports a check you ran. Hypher binds each to the working tree at the save and labels it an agent report (only agent reports are accepted until hooks capture checks in Baton M3); the next note says whether that tree still matches. It never runs the command. Only the command text, exit code, tree hash, and time leave the machine, never output. Against a server that does not accept evidence yet, the handoff saves without it and says so.
11
11
  - `hypher mcp` is the bridge for any MCP client: `resume`, `seal`, and `acknowledge`, with no project id, revision, or key to fill in. It also fills the repository and branch into a bare `read_ledger` call.
@@ -14,6 +14,20 @@ Pilot · not yet recorded. The bridge between coding agents and Hypher project m
14
14
  - `init` also puts `~/.hypher/bin` on PATH with one marked block in your shell's startup file (`~/.zshenv` for zsh, `~/.bash_profile` or `~/.bashrc` for bash, a `conf.d` file for fish), so `hypher` runs by name in new terminals and in agents' shells. `--no-path` skips it; hooks and the MCP bridge use the full path either way.
15
15
  - Inside Codex's sandbox the Keychain and the network are blocked. Commands run there say so instead of reporting that this computer is not connected.
16
16
 
17
+ ## Cloud sessions: `hypher init --repo` (0.3.0; built and rehearsed, not recorded)
18
+
19
+ A Claude Code cloud session runs the repository's `.claude/settings.json` hooks, not your user-level ones, and starts in a fresh container with no `hypher`. Run `hypher init --repo` on your laptop, inside the repository. It needs no sign-in, writes three files, and never commits or pushes; it prints what to commit.
20
+
21
+ - `.claude/settings.json`: Hypher's hooks (`SessionStart`, `Stop`, `PreCompact`, `SessionEnd`, and the two `PostToolUse` entries), each running `.claude/hooks/hypher.sh <Event>`, merged after your own hooks and settings (a second run changes nothing). `SessionStart` has a 120-second timeout, for a first-run install.
22
+ - `.claude/hooks/hypher.sh`: does nothing unless `CLAUDE_CODE_REMOTE=true`, so a laptop with the user-level hooks never fires twice; installs `@litterthanlit/hypher-cli` at the exact version that wrote it (never `latest`) if `hypher` is missing; prints one line naming `HYPHER_API_KEY` at session start if that variable is not set; otherwise runs `hypher hook claude-code <Event>`. It never prints the key.
23
+ - `AGENTS.md`: the marked block `init` writes. No `.mcp.json` is written.
24
+
25
+ **Commit** `.claude/settings.json`, `.claude/hooks/hypher.sh` (it must stay executable), and `AGENTS.md`. A committed hook runs a script at session start for anyone who clones, so Claude Code asks them to trust it: use it on your own repository first. **In the cloud environment** add `HYPHER_API_KEY`, a Hypher API key from Settings → API keys, named for that environment and revoked when you are done (it reaches your whole account). The environment must let `npm` reach the registry and `www.hypher.app`.
26
+
27
+ In a cloud session (`CLAUDE_CODE_REMOTE=true`, key from `HYPHER_API_KEY`) the CLI also: links a repository a person linked in Settings → Integrations without a click, keeping the answer in the container (a repository nobody linked uploads nothing and gets one line saying so; Hypher never makes a project); uploads each checkpoint as it is made, at most once a minute per repository and branch, with the newest queued one winning, and always at `Stop`, `PreCompact`, and `SessionEnd`; and names `hypher seal` in the shell first in the `Stop` request, since a container has no signed-in MCP entry. A laptop behaves as before.
28
+
29
+ `hypher doctor` inside the repository reports whether the kit is present and which version it pins. `hypher uninstall --repo` removes the kit and gives your files back as they were; plain `hypher uninstall` is unchanged. To move the pin, run `npx @litterthanlit/hypher-cli@latest init --repo` and commit the result. Details: `docs/doors/claude-code.md` in the repository.
30
+
17
31
  Only hashes, file names, repository and commit identity, and agent-written handoff text leave the machine. Never code, diffs, or transcripts.
18
32
 
19
33
  Canonical git helpers are vendored from `packages/hypher-git` (`node script/sync-hypher-git.mjs`). The note renderer, validators, secret scan, and the ledger's op checks, seal mapping, and local note are vendored from `hypher-web/shared` (`node script/sync-hypher-shared.mjs`). Do not edit `vendor/` by hand.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litterthanlit/hypher-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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/cli.mjs CHANGED
@@ -25,6 +25,7 @@ const OPTIONS = {
25
25
  full: { type: "boolean" },
26
26
  "no-browser": { type: "boolean" },
27
27
  "no-path": { type: "boolean" },
28
+ repo: { type: "boolean" },
28
29
  help: { type: "boolean", short: "h" },
29
30
  version: { type: "boolean", short: "v" },
30
31
  goal: { type: "string" },
@@ -51,7 +52,8 @@ Usage: hypher <command>
51
52
  init Connect this computer (one browser approval); add Claude Code and Codex hooks
52
53
  [--yes: never ask in the terminal] [--json: one JSON summary] [--no-browser]
53
54
  [--no-path: leave your shell's PATH alone]
54
- link Bind this repository to a Hypher project (one browser approval)
55
+ [--repo: write the cloud kit into this repository for Claude Code cloud sessions; commit it yourself]
56
+ link Bind this repository to a Hypher project (one browser approval)
55
57
  doctor Check every piece and print the fix for anything red
56
58
  status What is installed; --last shows exactly what was last sent
57
59
  resume Print the handoff note for this repository and its truth check
@@ -62,6 +64,7 @@ Usage: hypher <command>
62
64
  ack Acknowledge the handoff this session read
63
65
  mcp Run the stdio MCP bridge: resume, seal, acknowledge (for any MCP client)
64
66
  uninstall Remove exactly what init added and revoke the key
67
+ [--repo: remove only the cloud kit from this repository]
65
68
 
66
69
  Only hashes, file names, repository and commit identity, and agent-written
67
70
  handoff text leave this machine. Never code, diffs, or transcripts.`;
package/src/cloud.mjs ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Cloud mode (M3 slice 5): a Claude Code cloud session, started by the repository's committed kit
3
+ * (`hypher init --repo`). It is on when `CLAUDE_CODE_REMOTE=true` and the key is the one from
4
+ * `HYPHER_API_KEY` (a container has no Keychain). Three things differ from a laptop, and nothing else does:
5
+ *
6
+ * - No link file exists in a fresh container. A repository a human linked in Hypher is linked here without a click
7
+ * (`resolve_project_for_repo`, as `hypher link` already does when Hypher knows the repository), and the answer is
8
+ * kept in the container's `~/.hypher`. A repository nobody linked uploads nothing and says so in one line. Hypher
9
+ * never creates a project.
10
+ * - The container's disk dies with the session, so a checkpoint uploads at once instead of at the next session: at
11
+ * most one upload a minute per repository and branch (a newer queued checkpoint replaces an older one, so the
12
+ * newest wins), and `Stop`, `PreCompact`, and `SessionEnd` always try.
13
+ * - The seal request names `hypher seal` in the shell first (see hooks/index.mjs): no signed-in MCP entry exists.
14
+ */
15
+ import path from "node:path";
16
+ import { createClient, HypherRejected, HypherUnavailable } from "./api.mjs";
17
+ import { linkFor, saveLink } from "./config.mjs";
18
+ import { loadCredential } from "./credentials.mjs";
19
+ import { paths } from "./env.mjs";
20
+ import { readJson, sha256, writeJson } from "./fsutil.mjs";
21
+ import { flushOutbox } from "./outbox.mjs";
22
+
23
+ /** One upload a minute per repository and branch, outside the three events that always try. */
24
+ export const UPLOAD_WINDOW_MS = 60_000;
25
+ /** After an unmatched or unreachable answer, how long a hook that is not a session start waits before asking again. */
26
+ const UNAVAILABLE_RETRY_MS = 60_000;
27
+
28
+ /** True in any Claude Code cloud session, with or without a key. */
29
+ export function inCloud(env = process.env) {
30
+ return env?.CLAUDE_CODE_REMOTE === "true";
31
+ }
32
+
33
+ /** Cloud mode: a cloud session whose key is `HYPHER_API_KEY`. The cheap test goes first: a laptop never reads a credential here. */
34
+ export function cloudMode(env = process.env) {
35
+ return inCloud(env) && loadCredential(env).source === "HYPHER_API_KEY";
36
+ }
37
+
38
+ export const CLOUD_NOT_CONNECTED_LINE =
39
+ "Hypher is not connected in this cloud session; starting without it. Add HYPHER_API_KEY (a Hypher API key) to the cloud environment.";
40
+
41
+ export function cloudUnlinkedLine(repository) {
42
+ return `Hypher: ${String(repository).slice(0, 200)} is not linked to a Hypher project, so nothing is uploaded from this session. A person links it in Settings → Integrations at hypher.app; Hypher never creates a project on its own.`;
43
+ }
44
+
45
+ function cloudFile(env, name) {
46
+ return path.join(paths(env).stateDir, "cloud", name);
47
+ }
48
+
49
+ function readMap(file) {
50
+ const value = readJson(file, null);
51
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
52
+ }
53
+
54
+ /**
55
+ * The project this repository is linked to. On a laptop that is the link file, as always. In cloud mode a
56
+ * repository with no link asks Hypher whether a human linked it, and keeps the answer. A session start (`force`)
57
+ * always asks; any other hook asks only if the last answer was neither "no project" nor a failure in the last minute,
58
+ * so a repository nobody linked costs one request per session start, not one per edit.
59
+ * @param {NodeJS.ProcessEnv} env
60
+ * @param {string} repository owner/repo
61
+ * @param {{ force?: boolean, now?: number, timeoutMs?: number }} [options]
62
+ * @returns {Promise<{ link: object | null, status: "linked" | "unlinked" | "unmatched" | "not-connected" | "unavailable" | "rejected", error?: string }>}
63
+ */
64
+ export async function findLink(env, repository, { force = false, now = Date.now(), timeoutMs = 2_500 } = {}) {
65
+ const known = linkFor(repository, env);
66
+ if (known) return { link: known, status: "linked" };
67
+ if (!inCloud(env)) return { link: null, status: "unlinked" };
68
+ const client = createClient({ env, timeoutMs });
69
+ if (!client.apiKey) return { link: null, status: "not-connected" };
70
+
71
+ const file = cloudFile(env, "resolve.json");
72
+ const key = String(repository).toLowerCase();
73
+ const before = readMap(file)[key];
74
+ if (!force && before) {
75
+ if (before.status === "unmatched") return { link: null, status: "unmatched" };
76
+ if (now >= before.at && now - before.at < UNAVAILABLE_RETRY_MS) return { link: null, status: before.status };
77
+ }
78
+ const remember = (status) => {
79
+ try {
80
+ writeJson(file, { ...readMap(file), [key]: { status, at: now } }, { mode: 0o600 });
81
+ } catch {
82
+ // Only saves a request; never fail a hook over it.
83
+ }
84
+ };
85
+ try {
86
+ const { structured } = await client.tool("resolve_project_for_repo", { repo: repository });
87
+ if (structured.matched && structured.projectId) {
88
+ const link = { projectId: structured.projectId, projectName: structured.projectName };
89
+ saveLink(repository, link, env);
90
+ return { link: linkFor(repository, env), status: "linked" };
91
+ }
92
+ remember("unmatched");
93
+ return { link: null, status: "unmatched" };
94
+ } catch (error) {
95
+ if (error instanceof HypherUnavailable) {
96
+ remember("unavailable");
97
+ return { link: null, status: "unavailable" };
98
+ }
99
+ if (error instanceof HypherRejected) {
100
+ remember("rejected");
101
+ return { link: null, status: "rejected", error: error.message };
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ function uploadWindowFile(env) {
108
+ return cloudFile(env, "upload.json");
109
+ }
110
+
111
+ /**
112
+ * True, and counted, when a checkpoint of this repository and branch may upload now; false inside the minute after the
113
+ * last one. Counted before the request, so two hooks started together do not both upload. A time in the future (a
114
+ * clock that moved back) does not block.
115
+ */
116
+ export function claimUpload(env, key, now) {
117
+ const file = uploadWindowFile(env);
118
+ const all = readMap(file);
119
+ const hash = sha256(String(key)).slice(0, 20);
120
+ const last = all[hash];
121
+ if (typeof last === "number" && now >= last && now - last < UPLOAD_WINDOW_MS) return false;
122
+ writeJson(file, { ...all, [hash]: now }, { mode: 0o600 });
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Upload what is queued. `always` is for `Stop`, `PreCompact`, and `SessionEnd`: no window, no wait for a retry
128
+ * time. Anything else uploads at most once a minute per repository and branch; a checkpoint that waits stays
129
+ * queued, and a newer one replaces it there.
130
+ * @param {NodeJS.ProcessEnv} env
131
+ * @param {{ repository: string, branch: string }} snapshot
132
+ * @param {{ always?: boolean, now?: number, timeoutMs?: number }} [options]
133
+ */
134
+ export async function uploadNow(env, snapshot, { always = false, now = Date.now(), timeoutMs = 2_500 } = {}) {
135
+ const key = `${snapshot.repository.toLowerCase()}#${snapshot.branch}`;
136
+ if (!always && !claimUpload(env, key, now)) return null;
137
+ if (always) {
138
+ try {
139
+ claimUpload(env, key, now);
140
+ } catch {
141
+ // The window is a convenience here.
142
+ }
143
+ }
144
+ return await flushOutbox(createClient({ env, timeoutMs }), { env, now, force: always }).catch(() => null);
145
+ }
@@ -13,6 +13,7 @@ import { listOutbox } from "../outbox.mjs";
13
13
  import { readBytes, readJson } from "../fsutil.mjs";
14
14
  import { CODEX_HOOKS, codexHookTrust, codexHypherEntry, codexSupportsHooks, installedCodexHookEvents, readCodexHooks } from "../codex.mjs";
15
15
  import { hasBlock } from "../agentsBlock.mjs";
16
+ import { inspectRepoKit } from "../repoKit.mjs";
16
17
  import { remotesExposingHypherRefs, repoIdentity } from "../../vendor/hypher-git.mjs";
17
18
 
18
19
  function pad(text, width = 22) {
@@ -124,6 +125,19 @@ export async function run({ io }) {
124
125
  add("✓", "refs/hypher", "stay local (no remote pushes them)");
125
126
  }
126
127
  }
128
+ if (identity.root) {
129
+ const kit = inspectRepoKit(identity.root);
130
+ if (kit.state === "none") {
131
+ add("–", "Repo kit", "not in this repository (`hypher init --repo` adds it for Claude Code cloud sessions)");
132
+ } else if (kit.state === "partial") {
133
+ add("✗", "Repo kit", kit.problems.join("; "), "run `hypher init --repo` here and commit the result.");
134
+ } else if (kit.version === CLI_VERSION) {
135
+ add("✓", "Repo kit", `present · pinned @litterthanlit/hypher-cli@${kit.version} · hooks ${kit.events.join(", ")}`);
136
+ } else {
137
+ add("!", "Repo kit", `present · pinned @litterthanlit/hypher-cli@${kit.version ?? "unknown"}; this copy is ${CLI_VERSION} · hooks ${kit.events.join(", ")}`,
138
+ "to move the pin, run `npx @litterthanlit/hypher-cli@latest init --repo` here and commit the result.");
139
+ }
140
+ }
127
141
 
128
142
  const client = createClient({ env, timeoutMs: 5_000 });
129
143
  if (credential.apiKey) {
@@ -22,7 +22,8 @@ import { renderJson } from "../hookConfig.mjs";
22
22
  import { addBlock, agentsBlock, alreadyUsesHypher } from "../agentsBlock.mjs";
23
23
  import { addRepositoryBlock, displayPath } from "../repoBlock.mjs";
24
24
  import { addPathBlock, manualPathLine, pathBlock, shellStartupFile } from "../shellPath.mjs";
25
- import { repoIdentity } from "../../vendor/hypher-git.mjs";
25
+ import { installRepoKit } from "../repoKit.mjs";
26
+ import { git, repoIdentity } from "../../vendor/hypher-git.mjs";
26
27
 
27
28
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
28
29
 
@@ -52,12 +53,62 @@ export function installPinnedCopy(env, manifest) {
52
53
  return change;
53
54
  }
54
55
 
56
+ /**
57
+ * `hypher init --repo`: write the repo kit (repoKit.mjs) into the repository you are in, so Claude Code cloud sessions
58
+ * run Hypher's hooks. Needs no sign-in and touches nothing outside the repository. Never commits and never pushes:
59
+ * it prints what to commit.
60
+ */
61
+ async function runRepo({ flags, io }) {
62
+ const identity = repoIdentity(io.cwd);
63
+ if (!identity.root) {
64
+ io.err("hypher init --repo: run this inside a git repository.");
65
+ return 2;
66
+ }
67
+ const done = installRepoKit(identity.root, CLI_VERSION);
68
+ if (!done.ok) {
69
+ io.err(`hypher init --repo: ${done.error}`);
70
+ return 1;
71
+ }
72
+ // Only files git sees as new or modified need committing; a rerun on a committed kit says so.
73
+ const dirty = (git(identity.root, ["status", "--porcelain", "--untracked-files=all", "--", ...done.files]) ?? "").split("\n").filter(Boolean);
74
+ const toCommit = done.files.filter((file) => dirty.some((line) => line.slice(3) === file));
75
+ const say = flags.json ? (line = "") => io.err(line) : (line = "") => io.out(line);
76
+ say(`Hypher · cloud kit (hypher-cli ${CLI_VERSION}, pinned in the script)`);
77
+ say("");
78
+ if (done.changes.length) {
79
+ say(" Changed");
80
+ for (const [file, what] of done.changes) say(` ${pad(file, 28)}${what}`);
81
+ } else {
82
+ say(" Already set up. Nothing changed.");
83
+ }
84
+ say("");
85
+ if (toCommit.length) {
86
+ say(" Hypher did not commit or push anything. To turn it on for cloud sessions, commit these files:");
87
+ say(` git add ${toCommit.join(" ")}`);
88
+ say(' git commit -m "Add Hypher project memory for Claude Code cloud sessions"');
89
+ say(" hypher.sh must stay executable; git add records that. A committed hook runs a script at session start for anyone who clones, so Claude Code asks them to trust it.");
90
+ } else {
91
+ say(" These files are already committed.");
92
+ }
93
+ say("");
94
+ say(" In the Claude Code cloud environment for this repository, add a variable:");
95
+ say(" HYPHER_API_KEY = a Hypher API key (Settings → API keys, named for that environment, revoked when you are done)");
96
+ say(` ${API_KEYS_URL}`);
97
+ say(` Hypher uploads nothing until a person has linked ${identity.repository ?? "this repository"} to a project in Settings → Integrations.${identity.repository ? "" : " (This repository has no GitHub remote, so it cannot be linked.)"}`);
98
+ say(" Undo: hypher uninstall --repo");
99
+ if (flags.json) {
100
+ io.out(JSON.stringify({ ok: true, version: CLI_VERSION, alreadySetUp: done.changes.length === 0, changed: done.changes.map(([file, what]) => ({ file, what })), commit: toCommit, env: ["HYPHER_API_KEY"] }));
101
+ }
102
+ return 0;
103
+ }
104
+
55
105
  /**
56
106
  * `--yes` never asks in the terminal (today init asks nothing there; the one
57
107
  * approval is in the browser). `--json` prints one JSON summary on stdout.
58
108
  * @param {{ args: string[], flags: Record<string, any>, io: any }} input
59
109
  */
60
110
  export async function run({ flags, io }) {
111
+ if (flags.repo) return runRepo({ flags, io });
61
112
  const env = io.env;
62
113
  const started = Date.now();
63
114
  let approvalMs = 0;
@@ -16,6 +16,8 @@ import { removeBlock } from "../agentsBlock.mjs";
16
16
  import { removePathBlock } from "../shellPath.mjs";
17
17
  import { claudeHypherServer, claudeMcpRemove, detectTools } from "../tools.mjs";
18
18
  import { readJson, writeAtomic } from "../fsutil.mjs";
19
+ import { uninstallRepoKit } from "../repoKit.mjs";
20
+ import { repoIdentity } from "../../vendor/hypher-git.mjs";
19
21
 
20
22
  /**
21
23
  * How to take only Hypher's entries out of a file edited since install.
@@ -43,7 +45,29 @@ function readLine(stdin) {
43
45
  });
44
46
  }
45
47
 
48
+ /**
49
+ * `hypher uninstall --repo`: take the cloud kit out of this repository and nothing else. It needs no manifest in
50
+ * ~/.hypher and does not touch the key, the credential, or the hooks on this computer. Plain `uninstall` is unchanged.
51
+ */
52
+ function runRepo({ io }) {
53
+ const identity = repoIdentity(io.cwd);
54
+ if (!identity.root) {
55
+ io.err("hypher uninstall --repo: run this inside a git repository.");
56
+ return 2;
57
+ }
58
+ const undone = uninstallRepoKit(identity.root);
59
+ if (undone.length === 0) {
60
+ io.out("The Hypher cloud kit is not in this repository. Nothing to undo.");
61
+ return 0;
62
+ }
63
+ io.out("Hypher cloud kit removed from this repository.");
64
+ for (const line of undone) io.out(` ✓ ${line}`);
65
+ io.out(" Nothing was committed. Review with `git status`, then commit the removal.");
66
+ return 0;
67
+ }
68
+
46
69
  export async function run({ flags, io }) {
70
+ if (flags.repo) return runRepo({ io });
47
71
  const env = io.env;
48
72
  const p = paths(env);
49
73
  const manifest = readManifest(env);
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.2.1";
10
+ export const CLI_VERSION = "0.3.0";
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";
@@ -35,8 +35,9 @@ function withoutTagged(groups, tag) {
35
35
  * @param {{ event: string, matcher?: string, timeout: number }[]} specs
36
36
  * @param {string} shim absolute path of the `hypher` shim
37
37
  * @param {string} tag
38
+ * @param {(shim: string, tag: string, event: string) => string} [build] how a spec becomes a command; the repo kit's differs
38
39
  */
39
- export function mergeHooks(config, specs, shim, tag) {
40
+ export function mergeHooks(config, specs, shim, tag, build = hookCommand) {
40
41
  const next = { ...config, hooks: { ...(config.hooks ?? {}) } };
41
42
  // Hypher's old groups for an event all go first, so two specs of one event never remove each other.
42
43
  for (const event of new Set(specs.map((spec) => spec.event))) {
@@ -45,7 +46,7 @@ export function mergeHooks(config, specs, shim, tag) {
45
46
  for (const spec of specs) {
46
47
  next.hooks[spec.event].push({
47
48
  ...(spec.matcher ? { matcher: spec.matcher } : {}),
48
- hooks: [{ type: "command", command: hookCommand(shim, tag, spec.event), timeout: spec.timeout }],
49
+ hooks: [{ type: "command", command: build(shim, tag, spec.event), timeout: spec.timeout }],
49
50
  });
50
51
  }
51
52
  return next;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Agent hooks, for Claude Code and Codex alike (both send the same payload
3
3
  * shape). SessionStart injects the note (after startup, resume, clear, and
4
- * compact); Stop, PreCompact, and SessionEnd record a checkpoint; Stop asks
4
+ * compact; startup and resume move the seal baseline to the tree now, compact and clear keep it, so edits made
5
+ * before a compaction still make the next Stop ask for a seal); Stop, PreCompact, and SessionEnd record a checkpoint; Stop asks
5
6
  * once for a seal when the tree changed. Hooks never acknowledge, never block
6
7
  * twice in a row, and never block on Hypher being down. In a repository no
7
8
  * human linked they record and upload nothing.
@@ -10,9 +11,15 @@
10
11
  * after a tool ran (PostToolUse: a file edit at most every 10 seconds, a command at most every 60), so a session
11
12
  * killed in the middle of a turn still leaves one. PostToolUse reads only `cwd` and `tool_name` from its payload:
12
13
  * the rest is the tool's input, which can be file contents, and is never looked at.
14
+ *
15
+ * In a Claude Code cloud session (cloud.mjs: `CLAUDE_CODE_REMOTE=true`, key from `HYPHER_API_KEY`) the container's
16
+ * disk dies with it, so every checkpoint also uploads at once (at most one a minute per repository and branch; `Stop`,
17
+ * `PreCompact`, and `SessionEnd` always try), a repository a human linked in Hypher is linked without a click, and the
18
+ * seal request names the shell command first. On a laptop none of that applies.
13
19
  */
14
20
  import { createClient } from "../api.mjs";
15
21
  import { linkFor } from "../config.mjs";
22
+ import { cloudMode, findLink, uploadNow } from "../cloud.mjs";
16
23
  import { enqueue, flushOutbox, shouldFlush } from "../outbox.mjs";
17
24
  import { resumeHere } from "../session.mjs";
18
25
  import { claimCheckpoint, toolKind } from "../throttle.mjs";
@@ -24,11 +31,14 @@ export const ASK_AGAIN_AFTER_MS = 15 * 60_000;
24
31
  const HOOK_TIMEOUT_MS = 2_500;
25
32
  const MAX_FILES = 200;
26
33
 
27
- /** The linked snapshot at cwd, or null: not git, no remote, or not linked. */
28
- function linkedSnapshot(env, cwd) {
34
+ /**
35
+ * The linked snapshot at cwd, or null: not git, no remote, or not linked. In a cloud session with no link file yet,
36
+ * a repository a human linked in Hypher is linked here first (see cloud.mjs).
37
+ */
38
+ async function linkedSnapshot(env, cwd) {
29
39
  const snapshot = takeSnapshot(cwd);
30
40
  if (!snapshot?.repository || !snapshot.tree) return null;
31
- const link = linkFor(snapshot.repository, env);
41
+ const { link } = await findLink(env, snapshot.repository);
32
42
  return link ? { snapshot, link } : null;
33
43
  }
34
44
 
@@ -58,7 +68,7 @@ export function recordCheckpoint(env, snapshot, event, now = Date.now(), agent =
58
68
  * What the Stop hook asks for. The words are the same for every project, except that when the last resume found
59
69
  * the decision ledger on, IDs are named: a decision is replaced by its ID, and the lists are said to be added to.
60
70
  */
61
- function sealRequest(firstHandoff, ledgerOn = false) {
71
+ function sealRequest(firstHandoff, ledgerOn = false, cloud = false) {
62
72
  const goal = firstHandoff ? ' --goal "<the goal>"' : "";
63
73
  const pairs = ledgerOn
64
74
  ? "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>\")."
@@ -66,7 +76,10 @@ function sealRequest(firstHandoff, ledgerOn = false) {
66
76
  return [
67
77
  "Hypher: the working tree changed since the last handoff, and no one saved why.",
68
78
  "Before you stop, record the decisions that changed, the next action for the next agent, and the checks you ran with their exit codes:",
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.`,
79
+ cloud
80
+ // A cloud session has no signed-in hypher MCP server, so the shell command comes first.
81
+ ? `run \`hypher seal${goal} --decision "<a decision that changed>" --why "<why>" --next "<the next action>"\` in the shell (a cloud session has no signed-in Hypher MCP server), or, only if that command is not available, call the \`seal\` tool of the hypher MCP server.`
82
+ : `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.`,
70
83
  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 \"…\").",
71
84
  pairs,
72
85
  "It sends only your text, file names, and commit identity. Hypher asks once; if you cannot save now, say so and stop.",
@@ -77,10 +90,12 @@ function sealRequest(firstHandoff, ledgerOn = false) {
77
90
  const PERSON_SEES = new Set(["unavailable", "not-connected", "unlinked"]);
78
91
 
79
92
  /** The SessionStart checkpoint, from the snapshot the read already took. It never fails a session start. */
80
- function recordStartCheckpoint(env, snapshot, agent) {
93
+ async function recordStartCheckpoint(env, snapshot, agent) {
81
94
  try {
82
95
  if (!snapshot?.repository || !snapshot.tree || !linkFor(snapshot.repository, env)) return;
83
96
  recordCheckpoint(env, snapshot, "SessionStart", Date.now(), agent);
97
+ // A container's disk dies with it, so the checkpoint leaves at once (at most once a minute).
98
+ if (cloudMode(env)) await uploadNow(env, snapshot);
84
99
  } catch {
85
100
  // The note is already read; a checkpoint that could not be written is not worth a failed start.
86
101
  }
@@ -92,23 +107,28 @@ async function sessionStart(agent, env, input) {
92
107
  cwd: input.cwd || process.cwd(),
93
108
  destination: agent,
94
109
  sessionId: typeof input.session_id === "string" ? input.session_id : undefined,
110
+ // Claude Code and Codex send the same names. After `compact` or `clear` the work goes on, so the seal baseline stays.
111
+ source: typeof input.source === "string" ? input.source : undefined,
95
112
  timeoutMs: HOOK_TIMEOUT_MS,
96
113
  });
97
114
  if (result.status === "not-git" || !result.note) return null;
98
115
  // After the note is read and rendered, so the note never names its own checkpoint. Queued, not uploaded:
99
116
  // the next session on this computer sends it.
100
- if (agent === "claude-code") recordStartCheckpoint(env, result.snapshot, agent);
117
+ if (agent === "claude-code") await recordStartCheckpoint(env, result.snapshot, agent);
101
118
  const output = { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: result.note } };
102
119
  // The person sees these three too: the model alone may never mention that Hypher is not there.
103
120
  return PERSON_SEES.has(result.status) ? { systemMessage: result.note, ...output } : output;
104
121
  }
105
122
 
106
123
  async function stop(agent, env, input, now = Date.now()) {
107
- const found = linkedSnapshot(env, input.cwd || process.cwd());
124
+ const found = await linkedSnapshot(env, input.cwd || process.cwd());
108
125
  if (!found) return null;
109
126
  const { snapshot } = found;
110
127
  recordCheckpoint(env, snapshot, "Stop", now, agent);
111
- if (shouldFlush(env, now)) {
128
+ const cloud = cloudMode(env);
129
+ if (cloud) {
130
+ await uploadNow(env, snapshot, { always: true, now });
131
+ } else if (shouldFlush(env, now)) {
112
132
  await flushOutbox(createClient({ env, timeoutMs: HOOK_TIMEOUT_MS }), { env, now }).catch(() => null);
113
133
  }
114
134
 
@@ -127,7 +147,7 @@ async function stop(agent, env, input, now = Date.now()) {
127
147
  updateWorktreeState(env, snapshot.root, (current) => withBranchState(current, snapshot.branch, {
128
148
  askedAt: now, askedTree: snapshot.tree, blockedLast: true,
129
149
  }));
130
- return { decision: "block", reason: sealRequest(!state.proposal, state.ledger?.on === true) };
150
+ return { decision: "block", reason: sealRequest(!state.proposal, state.ledger?.on === true, cloud) };
131
151
  }
132
152
 
133
153
  /**
@@ -141,16 +161,20 @@ async function postToolUse(agent, env, input, now = Date.now()) {
141
161
  if (!kind) return null;
142
162
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
143
163
  if (!claimCheckpoint(env, cwd, kind, now)) return null;
144
- const found = linkedSnapshot(env, cwd);
145
- if (found) recordCheckpoint(env, found.snapshot, "PostToolUse", now, agent);
164
+ const found = await linkedSnapshot(env, cwd);
165
+ if (!found) return null;
166
+ recordCheckpoint(env, found.snapshot, "PostToolUse", now, agent);
167
+ // In a cloud session the checkpoint leaves at once, at most once a minute; on a laptop the next session sends it.
168
+ if (cloudMode(env)) await uploadNow(env, found.snapshot, { now });
146
169
  return null;
147
170
  }
148
171
 
149
172
  async function checkpointAndUpload(agent, env, input, event, now = Date.now()) {
150
- const found = linkedSnapshot(env, input.cwd || process.cwd());
173
+ const found = await linkedSnapshot(env, input.cwd || process.cwd());
151
174
  if (!found) return null;
152
175
  recordCheckpoint(env, found.snapshot, event, now, agent);
153
- await flushOutbox(createClient({ env, timeoutMs: HOOK_TIMEOUT_MS }), { env, now }).catch(() => null);
176
+ if (cloudMode(env)) await uploadNow(env, found.snapshot, { always: true, now });
177
+ else await flushOutbox(createClient({ env, timeoutMs: HOOK_TIMEOUT_MS }), { env, now }).catch(() => null);
154
178
  return null;
155
179
  }
156
180
 
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The repo kit (M3 slice 5): what `hypher init --repo` writes into a repository so a Claude Code cloud session runs
3
+ * Hypher's hooks. A cloud session runs the repository's `.claude/settings.json` hooks, not the user's, and a fresh
4
+ * container has no `hypher`, so the kit is three files a person commits:
5
+ *
6
+ * - `.claude/settings.json`: Hypher's hooks (the same events as the laptop's), each running `hypher.sh <Event>`,
7
+ * merged into whatever is there. They are found again by the script's path in their command.
8
+ * - `.claude/hooks/hypher.sh`: does nothing outside a cloud session, installs the exact CLI version that wrote it when
9
+ * `hypher` is missing, says which variable to add when the key is missing, else runs `hypher hook claude-code`.
10
+ * - `AGENTS.md`: the marked block `init` writes today.
11
+ *
12
+ * `init --repo` never commits or pushes, and never writes `.mcp.json`: a project-level `hypher` entry overrides the
13
+ * user's own, and a container has no browser for the sign-in. What was there before is kept in the repository's git
14
+ * directory (never committed), so `uninstall --repo` can give a file back byte for byte; without that record (a fresh
15
+ * clone) it removes only Hypher's entries and deletes nothing it cannot prove it created.
16
+ */
17
+ import { chmodSync, existsSync, statSync } from "node:fs";
18
+ import path from "node:path";
19
+ import { removeBlock } from "./agentsBlock.mjs";
20
+ import { CLAUDE_HOOKS } from "./claude.mjs";
21
+ import { installedEvents, installedSpecs, mergeHooks, removeHooks } from "./hookConfig.mjs";
22
+ import { readBytes, readJson, removeFile, writeAtomic, writeJson } from "./fsutil.mjs";
23
+ import { undoTrackedFile, writeTrackedFile } from "./manifest.mjs";
24
+ import { addRepositoryBlock } from "./repoBlock.mjs";
25
+ import { git } from "../vendor/hypher-git.mjs";
26
+
27
+ export const KIT_SCRIPT = ".claude/hooks/hypher.sh";
28
+ export const KIT_SETTINGS = ".claude/settings.json";
29
+ export const KIT_AGENTS = "AGENTS.md";
30
+ /** What marks a hook command as Hypher's: the script it runs. */
31
+ export const KIT_TAG = KIT_SCRIPT;
32
+ const SCRIPT_MARKER = "`hypher init --repo`";
33
+ /** SessionStart may install the CLI on a first run; the other hooks keep slice 3's timeouts. */
34
+ export const KIT_SESSION_START_TIMEOUT = 120;
35
+
36
+ export const REPO_HOOKS = CLAUDE_HOOKS.map((spec) => (spec.event === "SessionStart" ? { ...spec, timeout: KIT_SESSION_START_TIMEOUT } : spec));
37
+
38
+ /** The command a settings entry runs. CLAUDE_PROJECT_DIR is the repository root; `.` covers a version that does not set it. */
39
+ export function kitCommand(_shim, _tag, event) {
40
+ return `"\${CLAUDE_PROJECT_DIR:-.}/${KIT_SCRIPT}" ${event}`;
41
+ }
42
+
43
+ const VERSION_SHAPE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/;
44
+ const PACKAGE = "@litterthanlit/hypher-cli";
45
+
46
+ /** The script, pinned to one exact version. Never "latest": this file runs on every clone. */
47
+ export function kitScript(version) {
48
+ if (!VERSION_SHAPE.test(String(version))) throw new Error(`not an exact version: ${version}`);
49
+ const pinned = `${PACKAGE}@${version}`;
50
+ return [
51
+ "#!/bin/sh",
52
+ `# Hypher project memory for Claude Code cloud sessions. Written by ${SCRIPT_MARKER}; removed by \`hypher uninstall --repo\`.`,
53
+ "# It does nothing outside a cloud session, so a laptop with Hypher's user-level hooks never fires twice.",
54
+ '[ "$CLAUDE_CODE_REMOTE" = "true" ] || exit 0',
55
+ 'if [ -z "$HYPHER_API_KEY" ]; then',
56
+ ' [ "$1" = "SessionStart" ] && echo "Hypher: add HYPHER_API_KEY (a Hypher API key) to this cloud environment to turn on project memory. Nothing is sent without it."',
57
+ " exit 0",
58
+ "fi",
59
+ "if ! command -v hypher >/dev/null 2>&1; then",
60
+ " # The exact version that wrote this file: a floating tag would run new code on every clone.",
61
+ ` npm install -g --silent --no-audit --no-fund ${pinned} </dev/null >/dev/null 2>&1`,
62
+ " if ! command -v hypher >/dev/null 2>&1; then",
63
+ ` [ "$1" = "SessionStart" ] && echo "Hypher: could not install ${pinned} (npm install failed); starting without Hypher."`,
64
+ " exit 0",
65
+ " fi",
66
+ "fi",
67
+ 'exec hypher hook claude-code "$1"',
68
+ "",
69
+ ].join("\n");
70
+ }
71
+
72
+ /** The version a script pins, or null when it is not Hypher's script. */
73
+ export function kitPinnedVersion(text) {
74
+ return String(text ?? "").match(/npm install [^\n]*@litterthanlit\/hypher-cli@([0-9A-Za-z.+-]+)/)?.[1] ?? null;
75
+ }
76
+
77
+ // ---------------------------------------------------------------- settings.json
78
+
79
+ export function mergeRepoHooks(settings) {
80
+ return mergeHooks(settings, REPO_HOOKS, "", KIT_TAG, kitCommand);
81
+ }
82
+
83
+ export function removeRepoHooks(settings) {
84
+ return removeHooks(settings, KIT_TAG);
85
+ }
86
+
87
+ function isPlainObject(value) {
88
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
89
+ }
90
+
91
+ /** True when every hook is there with exactly the command and timeout `init --repo` writes: nothing to change. */
92
+ export function repoHooksCurrent(settings) {
93
+ return REPO_HOOKS.every((spec) =>
94
+ (settings?.hooks?.[spec.event] ?? []).some((group) =>
95
+ (group?.matcher ?? undefined) === (spec.matcher ?? undefined)
96
+ && (group?.hooks ?? []).some((hook) => hook?.type === "command" && hook.command === kitCommand("", KIT_TAG, spec.event) && hook.timeout === spec.timeout)));
97
+ }
98
+
99
+ /** The events with a Hypher repo hook, each once. */
100
+ export function installedRepoEvents(settings) {
101
+ return installedEvents(settings, REPO_HOOKS, KIT_TAG);
102
+ }
103
+
104
+ /** The hooks the kit needs that are not in these settings, by event (and matcher for an event with two). */
105
+ export function missingRepoHooks(settings) {
106
+ const present = new Set(installedSpecs(settings, REPO_HOOKS, KIT_TAG));
107
+ return REPO_HOOKS.filter((spec) => !present.has(spec)).map((spec) => (REPO_HOOKS.filter((other) => other.event === spec.event).length > 1 ? `${spec.event} (${spec.matcher})` : spec.event));
108
+ }
109
+
110
+ /** The indentation a JSON file uses, so a merge does not reformat the builder's file. */
111
+ export function detectIndent(text) {
112
+ const match = String(text ?? "").match(/^([ \t]+)\S/m);
113
+ return match ? match[1] : " ";
114
+ }
115
+
116
+ /** Render settings the way the original was written: its indentation, and whether it ended with a newline. */
117
+ export function renderRepoSettings(value, original) {
118
+ const endsWithNewline = original === undefined || original === null || original === "" || /\n$/.test(original);
119
+ return `${JSON.stringify(value, null, detectIndent(original))}${endsWithNewline ? "\n" : ""}`;
120
+ }
121
+
122
+ /**
123
+ * Settings text without Hypher's repo hooks, keeping everything else and its formatting. `""` when nothing else is
124
+ * left and `created` says Hypher made the file (so it can be deleted); `null` when the text is not JSON we can
125
+ * rewrite.
126
+ */
127
+ export function cleanRepoSettingsText(text, { created = false } = {}) {
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(text);
131
+ } catch {
132
+ return null;
133
+ }
134
+ if (!isPlainObject(parsed)) return null;
135
+ const cleaned = removeRepoHooks(parsed);
136
+ // Nothing of Hypher's in it: the text is not ours to reformat.
137
+ if (JSON.stringify(cleaned) === JSON.stringify(parsed)) return text;
138
+ if (Object.keys(cleaned).length === 0) return created ? "" : "{}\n";
139
+ return renderRepoSettings(cleaned, text);
140
+ }
141
+
142
+ // ---------------------------------------------------------------- the record of what was there
143
+
144
+ /** Where the original bytes are kept: the repository's git directory, never committed or pushed. */
145
+ export function kitRecordFile(root) {
146
+ const common = git(root, ["rev-parse", "--git-common-dir"]);
147
+ return common ? path.resolve(root, common, "hypher-repo-kit.json") : null;
148
+ }
149
+
150
+ function readRecord(file) {
151
+ const record = file ? readJson(file, null) : null;
152
+ return record && Array.isArray(record.entries) ? record : { version: 1, entries: [] };
153
+ }
154
+
155
+ function saveRecord(file, record) {
156
+ if (!file) return;
157
+ if (record.entries.length > 0) writeJson(file, record, { mode: 0o600 });
158
+ else removeFile(file);
159
+ }
160
+
161
+ // ---------------------------------------------------------------- install, uninstall, inspect
162
+
163
+ /**
164
+ * Write the kit into the repository at `root`. Never commits, never pushes.
165
+ * @param {string} root
166
+ * @param {string} version the exact CLI version the script pins
167
+ * @returns {{ ok: true, changes: [string, string][], files: string[] } | { ok: false, error: string }}
168
+ */
169
+ export function installRepoKit(root, version) {
170
+ const recordFile = kitRecordFile(root);
171
+ const record = readRecord(recordFile);
172
+ const changes = [];
173
+
174
+ const settingsFile = path.join(root, KIT_SETTINGS);
175
+ const original = readBytes(settingsFile)?.toString("utf8");
176
+ let settings = {};
177
+ if (original !== undefined && original.trim() !== "") {
178
+ try {
179
+ settings = JSON.parse(original);
180
+ } catch {
181
+ return { ok: false, error: `${KIT_SETTINGS} is not valid JSON, so Hypher left it alone. Fix it, then run \`hypher init --repo\` again.` };
182
+ }
183
+ if (!isPlainObject(settings)) return { ok: false, error: `${KIT_SETTINGS} is not a JSON object, so Hypher left it alone.` };
184
+ }
185
+ if (!repoHooksCurrent(settings)) {
186
+ const change = writeTrackedFile(record, settingsFile, renderRepoSettings(mergeRepoHooks(settings), original), "repo-claude-settings");
187
+ if (change !== "unchanged") changes.push([KIT_SETTINGS, `+ hooks ${installedRepoEvents(mergeRepoHooks(settings)).join(", ")}, each running ${KIT_SCRIPT}`]);
188
+ }
189
+
190
+ const scriptFile = path.join(root, KIT_SCRIPT);
191
+ const script = writeTrackedFile(record, scriptFile, kitScript(version), "repo-script");
192
+ try {
193
+ chmodSync(scriptFile, 0o755);
194
+ } catch {
195
+ // Not fatal; doctor says when the script is not executable.
196
+ }
197
+ if (script !== "unchanged") changes.push([KIT_SCRIPT, `${script === "added" ? "+" : "updated:"} ${PACKAGE}@${version} pinned, runs only in a cloud session`]);
198
+
199
+ const block = addRepositoryBlock(record, root);
200
+ if (block.change !== "unchanged" && block.change !== "skipped") changes.push([KIT_AGENTS, "+ Hypher block (resume first, save at the end)"]);
201
+
202
+ saveRecord(recordFile, record);
203
+ const files = [KIT_SETTINGS, KIT_SCRIPT, ...(existsSync(path.join(root, KIT_AGENTS)) ? [KIT_AGENTS] : [])];
204
+ return { ok: true, changes, files, agents: block.change };
205
+ }
206
+
207
+ /**
208
+ * Take the kit out. A file Hypher recorded, and nobody changed since, goes back byte for byte (or is deleted if Hypher
209
+ * created it). A file changed since keeps those changes and loses only Hypher's entries. With no record, only
210
+ * Hypher's entries come out and no file is deleted.
211
+ * @returns {string[]} what was done, one line each
212
+ */
213
+ export function uninstallRepoKit(root) {
214
+ const recordFile = kitRecordFile(root);
215
+ const record = readRecord(recordFile);
216
+ const undone = [];
217
+ // The reverse of the install order, so a folder is empty by the time its last file is gone.
218
+ const targets = [
219
+ [KIT_AGENTS, () => removeBlock],
220
+ // Edited since, and not Hypher's to delete if it was there before: leave it.
221
+ [KIT_SCRIPT, (entry) => (text) => (entry?.originalBase64 === null && text.includes(SCRIPT_MARKER) ? "" : null)],
222
+ [KIT_SETTINGS, (entry) => (text) => cleanRepoSettingsText(text, { created: entry?.originalBase64 === null })],
223
+ ];
224
+ for (const [rel, cleaner] of targets) {
225
+ const file = path.join(root, rel);
226
+ const entry = record.entries.find((item) => item.kind === "file" && item.path === file);
227
+ const current = readBytes(file)?.toString("utf8");
228
+ if (entry) {
229
+ const result = undoTrackedFile(entry, cleaner(entry));
230
+ record.entries = record.entries.filter((item) => item !== entry);
231
+ undone.push(`${rel}: ${result === "cleaned" ? "kept your edits, removed Hypher's entries" : result}`);
232
+ } else if (current !== undefined) {
233
+ // Not recorded here (a fresh clone): take out Hypher's entries, delete nothing.
234
+ if (rel === KIT_SCRIPT) {
235
+ if (current.includes(SCRIPT_MARKER)) {
236
+ removeFile(file);
237
+ undone.push(`${rel}: removed`);
238
+ }
239
+ continue;
240
+ }
241
+ const cleaned = cleaner(null)(current);
242
+ if (cleaned !== null && cleaned !== current) {
243
+ writeAtomic(file, cleaned);
244
+ undone.push(`${rel}: removed Hypher's entries`);
245
+ }
246
+ }
247
+ }
248
+ saveRecord(recordFile, record);
249
+ return undone;
250
+ }
251
+
252
+ /**
253
+ * What a repository has of the kit, for `hypher doctor`.
254
+ * @returns {{ state: "none" | "present" | "partial", version: string | null, events: string[], problems: string[] }}
255
+ */
256
+ export function inspectRepoKit(root) {
257
+ const scriptFile = path.join(root, KIT_SCRIPT);
258
+ const script = readBytes(scriptFile)?.toString("utf8");
259
+ const settings = readJson(path.join(root, KIT_SETTINGS), {}) ?? {};
260
+ const events = installedRepoEvents(settings);
261
+ if (script === undefined && events.length === 0) return { state: "none", version: null, events, problems: [] };
262
+ const problems = [];
263
+ if (script === undefined) problems.push(`${KIT_SCRIPT} is missing`);
264
+ else {
265
+ if (!script.includes(SCRIPT_MARKER)) problems.push(`${KIT_SCRIPT} is not Hypher's script`);
266
+ try {
267
+ if ((statSync(scriptFile).mode & 0o111) === 0) problems.push(`${KIT_SCRIPT} is not executable (run chmod +x, then commit it)`);
268
+ } catch {
269
+ // Gone between the read and the stat.
270
+ }
271
+ }
272
+ const missing = missingRepoHooks(settings);
273
+ if (missing.length) problems.push(`hooks missing in ${KIT_SETTINGS}: ${missing.join(", ")}`);
274
+ return { state: problems.length ? "partial" : "present", version: script === undefined ? null : kitPinnedVersion(script), events, problems };
275
+ }
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);
@@ -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) {