@litterthanlit/hypher-cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/package.json +1 -1
- package/src/api.mjs +31 -6
- package/src/claude.mjs +22 -1
- package/src/cli.mjs +4 -1
- package/src/cloud.mjs +145 -0
- package/src/commands/doctor.mjs +41 -6
- package/src/commands/hook.mjs +1 -1
- package/src/commands/init.mjs +54 -3
- package/src/commands/status.mjs +37 -1
- package/src/commands/uninstall.mjs +24 -0
- package/src/env.mjs +1 -1
- package/src/hookConfig.mjs +19 -9
- package/src/hooks/index.mjs +78 -12
- package/src/outbox.mjs +37 -1
- package/src/repoKit.mjs +275 -0
- package/src/session.mjs +28 -11
- package/src/throttle.mjs +46 -0
- package/vendor/handoffNote.mjs +18 -4
- package/vendor/ledgerNoteLocal.mjs +1 -1
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/src/throttle.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How often a PostToolUse hook may record a checkpoint (M3 slice 3). One small file per working directory
|
|
3
|
+
* holds the time of the last checkpoint for each kind of tool, so a call that is too soon answers from that
|
|
4
|
+
* file alone, with no git call. A file edit may record every 10 seconds, a command every 60, each counted
|
|
5
|
+
* on its own: a command does not wait for an edit, nor an edit for a command.
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { paths } from "./env.mjs";
|
|
9
|
+
import { readJson, sha256, writeJson } from "./fsutil.mjs";
|
|
10
|
+
|
|
11
|
+
export const EDIT_WINDOW_MS = 10_000;
|
|
12
|
+
export const BASH_WINDOW_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
15
|
+
|
|
16
|
+
/** "edit" for a tool that changes files, "bash" for a command, else null (no checkpoint). */
|
|
17
|
+
export function toolKind(toolName) {
|
|
18
|
+
if (typeof toolName !== "string") return null;
|
|
19
|
+
if (EDIT_TOOLS.has(toolName)) return "edit";
|
|
20
|
+
return toolName === "Bash" ? "bash" : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const WINDOWS = { edit: EDIT_WINDOW_MS, bash: BASH_WINDOW_MS };
|
|
24
|
+
|
|
25
|
+
function throttleFile(env, cwd) {
|
|
26
|
+
return path.join(paths(env).stateDir, "throttle", `${sha256(String(cwd)).slice(0, 20)}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True, and counted, when a checkpoint of this kind may be recorded now; false when the last one is too
|
|
31
|
+
* recent. A last time in the future (a clock that moved back) does not block.
|
|
32
|
+
* @param {NodeJS.ProcessEnv} env
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
* @param {"edit" | "bash"} kind
|
|
35
|
+
* @param {number} now
|
|
36
|
+
*/
|
|
37
|
+
export function claimCheckpoint(env, cwd, kind, now) {
|
|
38
|
+
const file = throttleFile(env, cwd);
|
|
39
|
+
const saved = readJson(file, null);
|
|
40
|
+
const last = saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {};
|
|
41
|
+
const at = last[kind];
|
|
42
|
+
if (typeof at === "number" && now >= at && now - at < WINDOWS[kind]) return false;
|
|
43
|
+
// Counted before the git work, so two hooks started together do not both record.
|
|
44
|
+
writeJson(file, { ...last, [kind]: now }, { mode: 0o600 });
|
|
45
|
+
return true;
|
|
46
|
+
}
|
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) {
|