@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 +15 -1
- package/package.json +1 -1
- package/src/cli.mjs +4 -1
- package/src/cloud.mjs +145 -0
- package/src/commands/doctor.mjs +14 -0
- package/src/commands/init.mjs +52 -1
- package/src/commands/uninstall.mjs +24 -0
- package/src/env.mjs +1 -1
- package/src/hookConfig.mjs +3 -2
- package/src/hooks/index.mjs +39 -15
- package/src/repoKit.mjs +275 -0
- package/src/session.mjs +28 -11
- package/vendor/handoffNote.mjs +18 -4
- package/vendor/ledgerNoteLocal.mjs +1 -1
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
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
|
-
|
|
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
|
+
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -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) {
|
package/src/commands/init.mjs
CHANGED
|
@@ -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 {
|
|
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.
|
|
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";
|
package/src/hookConfig.mjs
CHANGED
|
@@ -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:
|
|
49
|
+
hooks: [{ type: "command", command: build(shim, tag, spec.event), timeout: spec.timeout }],
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
52
|
return next;
|
package/src/hooks/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
28
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
package/src/repoKit.mjs
ADDED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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:
|
|
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 =
|
|
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);
|
package/vendor/handoffNote.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|