@litterthanlit/hypher-cli 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ // Generated from hypher-web/shared/ledgerSeal.ts by script/sync-hypher-shared.mjs. Do not edit.
2
+ /**
3
+ * What `seal` means in a project whose decision ledger is on (M2 slice 6): the CLI's `seal` input (a goal,
4
+ * a next action, decisions with why, constraints, completed, unverified, open, blockers, checks, and raw
5
+ * ops) turned into one `record_ops` call, and the server's answer turned into the words the agent reads.
6
+ * Pure TypeScript with no database, network, Node, Convex, or Next.js, vendored into the CLI
7
+ * (`script/sync-hypher-shared.mjs`) so the hosted tests and the installed copy run the same code.
8
+ *
9
+ * Installed CLIs never update themselves, so what this sends is permanent: it sends only ops the
10
+ * ledger already defines (`ledgerOps.ts`), never an origin, a status, or an approval of its own, and the
11
+ * call is checked locally by `parseLedgerCall` (secrets, `FILL:`, lengths, 50 ops, 25,000 bytes) before
12
+ * anything leaves the machine.
13
+ *
14
+ * The order of the ops follows `planV1Save` in `ledgerV1.ts`: decisions and constraints first, then the
15
+ * `seal` (a new goal moves the branch's old tasks, blockers, and checks out of the note), then the tasks,
16
+ * blockers, and checks this save says stand under it, then any raw ops the agent sent.
17
+ *
18
+ * Lists are additive. A ledger holds more than one save's worth, so leaving a line out of `completed`,
19
+ * `unverified`, `open`, or `blockers` removes nothing. A task is closed by listing it under `completed`,
20
+ * a decision is replaced by naming its ID, and anything else is a raw op (`ops`), for example
21
+ * `decision.retire`, `blocker.resolve`, or `task.drop`.
22
+ */
23
+ import { noteText } from "./handoffNote.mjs";
24
+ import { evidenceFromSeal } from "./handoffSeal.mjs";
25
+ import { LEDGER_MAX_TEXT, parseLedgerCall } from "./ledgerOps.mjs";
26
+ /** A body larger than this does not fit `/api/mcp` (`MAX_BODY_BYTES` in the route). */
27
+ export const LEDGER_CALL_MAX_BYTES = 25_000;
28
+ /** The code of the answer a server gives for a project whose ledger is not on (`ledgerNotOnMessage` in ledgerApi.ts). */
29
+ export const LEDGER_NOT_ON_CODE = "ledger-not-on";
30
+ const lower = (value) => value.toLowerCase();
31
+ function clean(value) {
32
+ return typeof value === "string" ? value.trim() : "";
33
+ }
34
+ function cleanList(values) {
35
+ return Array.isArray(values) ? values.map(clean).filter(Boolean) : [];
36
+ }
37
+ /** The first words of a text, for the line that says what became of it. */
38
+ function short(text, max = 60) {
39
+ const flat = noteText(text, 400);
40
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
41
+ }
42
+ const DISPLAY_ID = /^d-[1-9]\d{0,8}$/i;
43
+ const WINDOW_NUMBER = /^D\d+$/i;
44
+ /** The numbering of the old note (D1, D2…), which a project with a ledger no longer has. */
45
+ export function windowNumberHint(ref) {
46
+ return `${ref} is a number from the old note, and decisions in this project have IDs like d-3. Use the ID shown in the note ("d-3: …"), or part of the old decision's words.`;
47
+ }
48
+ /**
49
+ * The active decision a text names: the exact text, or a unique case-insensitive part of it (three
50
+ * characters or more). `items` is newest first, as the read route lists them. When the list was cut at
51
+ * its page size, a miss says so and points at the ID.
52
+ */
53
+ export function resolveDecisionText(items, reference, complete = true) {
54
+ const ref = reference.trim();
55
+ const wanted = lower(ref);
56
+ const exact = items.find((item) => lower(item.text.trim()) === wanted);
57
+ if (exact)
58
+ return { ok: true, id: exact.id };
59
+ const partial = wanted.length >= 3 ? items.filter((item) => lower(item.text).includes(wanted)) : [];
60
+ if (partial.length === 1)
61
+ return { ok: true, id: partial[0].id };
62
+ const list = (rows) => rows.slice(0, 8).map((item) => `${item.id} "${short(item.text, 50)}"`).join(", ") + (rows.length > 8 ? `, and ${rows.length - 8} more` : "");
63
+ if (partial.length > 1)
64
+ return { ok: false, error: `"${short(ref, 50)}" matches ${partial.length} decisions (${list(partial)}); use the decision's ID, for example ${partial[0].id}.` };
65
+ const cut = complete ? "" : " Only the newest decisions were checked; use the decision's ID (`hypher resume --full` lists them).";
66
+ return { ok: false, error: `No active decision matches "${short(ref, 50)}".${items.length ? ` Decisions: ${list(items)}.` : " There are no decisions to replace."}${cut}` };
67
+ }
68
+ function fail(error) {
69
+ return { ok: false, error };
70
+ }
71
+ /**
72
+ * The ops for one seal. Nothing is sent from here. When a decision is replaced by its words, the active
73
+ * decisions are needed to find its ID: the first call answers `needsDecisions`, the caller reads them
74
+ * once and calls again with `decisions`.
75
+ *
76
+ * @param input.prior the window this session resumed from (null when none was kept)
77
+ * @param input.labeled true when the resume was for a branch with no workstream yet: it was shown the most
78
+ * recently active one, so its goal is carried onto the new branch when the seal gives none
79
+ * @param input.sessionRef the label of this session; it is the source of every decision
80
+ * @param input.now the time of the save; checks are stamped with it rounded down to the minute, so an
81
+ * identical retry within the minute is the same call
82
+ */
83
+ export function planLedgerSeal(input) {
84
+ const { seal, prior } = input;
85
+ const nextAction = clean(seal.nextAction);
86
+ if (!nextAction)
87
+ return fail("The next action is required: nextAction (in a shell, --next \"…\").");
88
+ const extra = seal.ops === undefined || seal.ops === null ? [] : seal.ops;
89
+ if (!Array.isArray(extra))
90
+ return fail("ops must be a list of ledger ops, each an object with a type, for example { \"type\": \"decision.retire\", \"id\": \"d-3\", \"reason\": \"…\" }.");
91
+ for (let index = 0; index < extra.length; index += 1) {
92
+ const op = extra[index];
93
+ if (!op || typeof op !== "object" || Array.isArray(op) || typeof op.type !== "string") {
94
+ return fail(`ops[${index}] must be an object with a type, for example { "type": "decision.retire", "id": "d-3", "reason": "…" }.`);
95
+ }
96
+ }
97
+ // First pass: everything that can be refused without reading the ledger.
98
+ let needsLookup = false;
99
+ for (const raw of seal.decisions ?? []) {
100
+ const text = clean(raw?.decision);
101
+ if (!text)
102
+ continue;
103
+ if (!clean(raw.why))
104
+ return fail(`Say why for "${text}" (--why "…" right after --decision).`);
105
+ const ref = clean(raw.replaces);
106
+ if (!ref || DISPLAY_ID.test(ref))
107
+ continue;
108
+ if (WINDOW_NUMBER.test(ref))
109
+ return fail(windowNumberHint(ref));
110
+ needsLookup = true;
111
+ }
112
+ if (needsLookup && !input.decisions)
113
+ return { ok: false, needsDecisions: true };
114
+ const ops = [];
115
+ const labels = [];
116
+ const push = (op, label) => {
117
+ ops.push(op);
118
+ labels.push(label);
119
+ };
120
+ // Decisions. A restatement of one the window already holds changes nothing, as in v1. The session's label is the
121
+ // source of each, cut to the length a source may have (v1 cuts it the same way).
122
+ const sessionRef = clean(input.sessionRef).slice(0, LEDGER_MAX_TEXT);
123
+ const known = new Set((prior?.decisions ?? []).map((item) => lower(item.decision.trim())));
124
+ const replaced = new Set();
125
+ for (const raw of seal.decisions ?? []) {
126
+ const text = clean(raw?.decision);
127
+ if (!text)
128
+ continue;
129
+ const reason = clean(raw.why);
130
+ const ref = clean(raw.replaces);
131
+ const label = `decision "${short(text)}"`;
132
+ if (ref) {
133
+ let id;
134
+ if (DISPLAY_ID.test(ref)) {
135
+ id = lower(ref);
136
+ }
137
+ else {
138
+ const found = resolveDecisionText(input.decisions ?? [], ref, input.decisionsComplete ?? true);
139
+ if (!found.ok)
140
+ return found;
141
+ id = found.id;
142
+ }
143
+ if (replaced.has(id))
144
+ return fail(`${id} is replaced twice.`);
145
+ replaced.add(id);
146
+ push({ type: "decision.supersede", id, text, reason, sourceRefs: [sessionRef] }, label);
147
+ }
148
+ else {
149
+ if (known.has(lower(text)))
150
+ continue;
151
+ known.add(lower(text));
152
+ push({ type: "decision.add", text, reason, sourceRefs: [sessionRef] }, label);
153
+ }
154
+ }
155
+ // Constraints are only added. One the window already holds is not sent again.
156
+ const constraints = new Set((prior?.constraints ?? []).map((item) => lower(item.trim())));
157
+ for (const text of cleanList(seal.constraints)) {
158
+ if (constraints.has(lower(text)))
159
+ continue;
160
+ constraints.add(lower(text));
161
+ push({ type: "constraint.add", text }, `constraint "${short(text)}"`);
162
+ }
163
+ // The seal. On a branch with no workstream the goal of the one the note showed is carried, so the new
164
+ // workstream has a goal; a goal the agent gives always wins, and without either none is sent.
165
+ const given = clean(seal.goal);
166
+ const goal = given || (input.labeled ? clean(prior?.goal) : "");
167
+ push({ type: "seal", ...(goal ? { goal } : {}), nextAction }, given ? "goal and next action" : "next action");
168
+ // Tasks. The fold matches an active task by its words on this branch and sets its status, so a line
169
+ // under `completed` closes one that was open or unverified. A line in two lists takes the later one.
170
+ const tasks = new Map();
171
+ for (const text of cleanList(seal.open))
172
+ tasks.set(lower(text), { text, status: "open" });
173
+ for (const text of cleanList(seal.unverified))
174
+ tasks.set(lower(text), { text, status: "unverified" });
175
+ for (const text of cleanList(seal.completed))
176
+ tasks.set(lower(text), { text, status: "done" });
177
+ for (const task of tasks.values())
178
+ push({ type: "task.upsert", text: task.text, status: task.status }, `task "${short(task.text)}" (${task.status})`);
179
+ const blockers = new Set();
180
+ for (const text of cleanList(seal.blockers)) {
181
+ if (blockers.has(lower(text)))
182
+ continue;
183
+ blockers.add(lower(text));
184
+ push({ type: "blocker.add", text }, `blocker "${short(text)}"`);
185
+ }
186
+ // Checks, bound to the tree at the save and stamped to the minute.
187
+ const stamped = Math.floor(input.now / 60_000) * 60_000;
188
+ const reported = evidenceFromSeal(seal.evidence, input.tree, stamped);
189
+ if (!reported.ok)
190
+ return fail(reported.error);
191
+ for (const item of reported.items) {
192
+ push({ type: "evidence.record", command: item.command, exitCode: item.exitCode, tree: item.tree, capturedAt: item.capturedAt }, `check "${short(item.command)}" (exit ${item.exitCode})`);
193
+ }
194
+ // Raw ops last, as the agent wrote them.
195
+ extra.forEach((op, index) => push(op, `ops[${index}] ${noteText(op.type, 40)}`));
196
+ return { ok: true, ops, labels };
197
+ }
198
+ /** The words for an error `parseLedgerCall` gave about `ops[n]`, said about the item it came from. */
199
+ export function explainCallError(error, labels) {
200
+ const limit = /^A call carries at most (\d+) ops; this one has (\d+)\.$/.exec(error);
201
+ if (limit) {
202
+ return `Refused: this save would send ${limit[2]} ops, and one call carries at most ${limit[1]}. Nothing was sent. Save in two parts, the most important first.`;
203
+ }
204
+ const at = /^ops\[(\d+)\]\.?(.*)$/.exec(error);
205
+ const rest = (at ? at[2] || "is not valid" : error).replace(/\.$/, "");
206
+ const subject = at ? labels[Number(at[1])] ?? `ops[${at[1]}]` : "";
207
+ return `Refused: ${subject ? `${subject}: ` : ""}${rest}. Nothing was sent. Fix it and seal again.`;
208
+ }
209
+ /**
210
+ * Check the call as the server will: `parseLedgerCall` (secrets, `FILL:`, lengths, at most 50 ops) and the
211
+ * size of the whole request. A refusal is in plain words and nothing is sent.
212
+ */
213
+ export function checkLedgerCall(input) {
214
+ const parsed = parseLedgerCall({ baseRevision: input.baseRevision, repo: input.repo, ops: input.ops });
215
+ if (!parsed.ok)
216
+ return fail(explainCallError(parsed.error, input.labels));
217
+ const args = { source: input.source, projectId: input.projectId, baseRevision: input.baseRevision, repo: parsed.value.repo, ops: parsed.value.ops };
218
+ // The whole JSON-RPC request counts, not only the arguments.
219
+ const body = JSON.stringify({ jsonrpc: "2.0", id: 1_000_000, method: "tools/call", params: { name: "record_ops", arguments: args } });
220
+ const bytes = new TextEncoder().encode(body).length;
221
+ if (bytes > LEDGER_CALL_MAX_BYTES) {
222
+ return fail(`Refused: this save is ${bytes.toLocaleString("en-US")} bytes, and one call may carry at most ${LEDGER_CALL_MAX_BYTES.toLocaleString("en-US")}. Nothing was sent. Save in two parts, the most important first.`);
223
+ }
224
+ return { ok: true, ops: parsed.value.ops, args };
225
+ }
226
+ /** True for the answer a server gives when it has no such tool: an older Hypher, with no ledger. */
227
+ export function isUnknownToolError(message, code) {
228
+ return code === -32601 || /\bunknown[-\s]tool\b|\btool not found\b|\bmethod not found\b/i.test(message);
229
+ }
230
+ const KNOWN_STATUSES = ["applied", "merged", "noop", "disputed", "refused", "unsupported"];
231
+ function oneLine(text, max = 240) {
232
+ return noteText(text ?? "", max);
233
+ }
234
+ function resultLine(result, label) {
235
+ const note = result.note ? ` (${oneLine(result.note)})` : "";
236
+ switch (result.status) {
237
+ case "merged":
238
+ return `${label}: merged into ${result.id ?? "an existing item"}${note}`;
239
+ case "noop":
240
+ return `${label}: no change${note}`;
241
+ case "disputed":
242
+ return `${label}: disputed${result.subject ? ` on ${oneLine(result.subject, 40)}` : ""}${result.id ? `; your proposal is ${oneLine(result.id, 40)}` : ""}`;
243
+ case "refused":
244
+ return `${label}: refused${result.reason ? `. ${oneLine(result.reason)}` : ""}`;
245
+ case "unsupported":
246
+ return `${label}: not applied. This Hypher does not know the op type "${oneLine(result.type ?? "", 60)}"; it is kept in the log.`;
247
+ default: {
248
+ // A status this copy does not know: said as it came, with whatever the server attached.
249
+ const parts = [result.id, result.subject].filter(Boolean).map((item) => oneLine(item, 40));
250
+ return `${label}: ${oneLine(result.status, 40)}${parts.length ? ` ${parts.join(" ")}` : ""}${result.reason ? `. ${oneLine(result.reason)}` : ""}${note}`;
251
+ }
252
+ }
253
+ }
254
+ /**
255
+ * The answer to a saved `record_ops` call. It starts with the revision and the counts, then has one line
256
+ * for each op that was not simply applied, then each question for the user, then a line when other
257
+ * writers landed changes between the revision this session read and this save.
258
+ */
259
+ export function describeLedgerSave(input) {
260
+ const { answer } = input;
261
+ const results = answer.results ?? [];
262
+ const counts = answer.counts && typeof answer.counts === "object"
263
+ ? { ...answer.counts }
264
+ : results.reduce((all, item) => ({ ...all, [item.status]: (all[item.status] ?? 0) + 1 }), {});
265
+ const order = [...KNOWN_STATUSES, ...Object.keys(counts).filter((status) => !KNOWN_STATUSES.includes(status))];
266
+ const summary = order.filter((status) => (counts[status] ?? 0) > 0).map((status) => `${counts[status]} ${oneLine(status, 40)}`).join(", ") || "no ops";
267
+ const where = `for "${oneLine(input.projectName, 80)}" (branch ${oneLine(answer.branch ?? input.branch, 120)})`;
268
+ const lines = [
269
+ `Saved ledger revision ${answer.revision} ${where}: ${summary}. Memory did not transfer code.${answer.replay ? " This save was already recorded; nothing was added." : ""}`,
270
+ ...(input.extraLines ?? []),
271
+ ];
272
+ for (const result of results) {
273
+ if (result.status === "applied")
274
+ continue;
275
+ lines.push(`- ${resultLine(result, input.labels[result.index] ?? `ops[${result.index}] ${oneLine(result.type ?? "?", 40)}`)}`);
276
+ }
277
+ for (const question of answer.ask ?? [])
278
+ lines.push(`Ask the user: ${oneLine(question, 600)}`);
279
+ if (answer.revision > input.base + 1 && !answer.replay) {
280
+ lines.push(`Other changes landed since this session read the ledger (revision ${input.base}; it is now ${answer.revision}). Run \`hypher resume\` to read them before you save again.`);
281
+ }
282
+ return lines.join("\n");
283
+ }