@litterthanlit/hypher-cli 0.1.8 → 0.2.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 +4 -3
- package/package.json +1 -1
- package/src/api.mjs +2 -1
- package/src/cli.mjs +4 -0
- package/src/commands/mcp.mjs +32 -3
- package/src/commands/resume.mjs +20 -5
- package/src/commands/seal.mjs +10 -1
- package/src/env.mjs +1 -1
- package/src/hooks/index.mjs +10 -3
- package/src/ledger.mjs +134 -0
- package/src/session.mjs +209 -32
- package/vendor/handoffNote.mjs +15 -8
- package/vendor/handoffSeal.mjs +2 -1
- package/vendor/ledgerNoteLocal.mjs +122 -0
- package/vendor/ledgerOps.mjs +387 -0
- package/vendor/ledgerSeal.mjs +283 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// Generated from hypher-web/shared/ledgerOps.ts by script/sync-hypher-shared.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* Ops of the decision ledger (M2 slice 2): the shapes an agent sends, how they are checked, and the
|
|
4
|
+
* pure helpers around one call. Pure TypeScript with no database, network, Node, Convex, or Next.js,
|
|
5
|
+
* so the CLI can vendor it later. Nothing calls this yet.
|
|
6
|
+
*
|
|
7
|
+
* `type` is a string checked here, never a closed database literal. An op type this code does not
|
|
8
|
+
* know is kept as unsupported and not applied (the log can replay it once the fold learns it). A
|
|
9
|
+
* malformed known op refuses the whole call. Text limits match v1: 280 characters.
|
|
10
|
+
*/
|
|
11
|
+
import { findSecretInValue } from "./secretScan.mjs";
|
|
12
|
+
import { canonicalJson, defaultIdempotencyKey, parseRepoSnapshot, sha256Hex } from "./structuredHandoff.mjs";
|
|
13
|
+
/** Decision, reason, constraint, task, blocker, and next action text. `MAX_SHORT` in structuredHandoff. */
|
|
14
|
+
export const LEDGER_MAX_TEXT = 280;
|
|
15
|
+
/** `MAX_GOAL` in structuredHandoff. */
|
|
16
|
+
export const LEDGER_MAX_GOAL = 600;
|
|
17
|
+
/** `MAX_COMMAND` in structuredHandoff. */
|
|
18
|
+
export const LEDGER_MAX_COMMAND = 300;
|
|
19
|
+
export const LEDGER_MAX_OPS_PER_CALL = 50;
|
|
20
|
+
export const LEDGER_MAX_SOURCE_REFS = 8;
|
|
21
|
+
const MAX_KEY = 200;
|
|
22
|
+
const MAX_TYPE = 80;
|
|
23
|
+
/** An op this code does not know is logged as it came, so its size is bounded. */
|
|
24
|
+
const MAX_UNSUPPORTED_JSON = 4_000;
|
|
25
|
+
export const LEDGER_OP_TYPES = [
|
|
26
|
+
"decision.add",
|
|
27
|
+
"decision.supersede",
|
|
28
|
+
"decision.retire",
|
|
29
|
+
"decision.amend",
|
|
30
|
+
"decision.approve",
|
|
31
|
+
"constraint.add",
|
|
32
|
+
"constraint.retire",
|
|
33
|
+
"task.upsert",
|
|
34
|
+
"task.drop",
|
|
35
|
+
"blocker.add",
|
|
36
|
+
"blocker.resolve",
|
|
37
|
+
"evidence.record",
|
|
38
|
+
"seal",
|
|
39
|
+
];
|
|
40
|
+
function fail(error) {
|
|
41
|
+
return { ok: false, error };
|
|
42
|
+
}
|
|
43
|
+
function isObject(value) {
|
|
44
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
function unknownKey(value, allowed) {
|
|
47
|
+
for (const key of Object.keys(value)) {
|
|
48
|
+
if (!allowed.includes(key))
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function shortText(value, field, max = LEDGER_MAX_TEXT) {
|
|
54
|
+
if (typeof value !== "string")
|
|
55
|
+
return fail(`${field} must be a string`);
|
|
56
|
+
const cleaned = value.trim();
|
|
57
|
+
if (!cleaned)
|
|
58
|
+
return fail(`${field} is required`);
|
|
59
|
+
if (cleaned.length > max)
|
|
60
|
+
return fail(`${field} is too long`);
|
|
61
|
+
return { ok: true, value: cleaned };
|
|
62
|
+
}
|
|
63
|
+
function optionalRefs(value, field) {
|
|
64
|
+
if (value === undefined)
|
|
65
|
+
return { ok: true, value: undefined };
|
|
66
|
+
if (!Array.isArray(value))
|
|
67
|
+
return fail(`${field} must be an array`);
|
|
68
|
+
if (value.length < 1)
|
|
69
|
+
return fail(`${field} is required`);
|
|
70
|
+
if (value.length > LEDGER_MAX_SOURCE_REFS)
|
|
71
|
+
return fail(`${field} has too many items`);
|
|
72
|
+
const refs = [];
|
|
73
|
+
for (const item of value) {
|
|
74
|
+
const parsed = shortText(item, field);
|
|
75
|
+
if (!parsed.ok)
|
|
76
|
+
return parsed;
|
|
77
|
+
if (!refs.includes(parsed.value))
|
|
78
|
+
refs.push(parsed.value);
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, value: refs };
|
|
81
|
+
}
|
|
82
|
+
const ID_PATTERNS = {
|
|
83
|
+
d: /^d-[1-9]\d{0,8}$/,
|
|
84
|
+
c: /^c-[1-9]\d{0,8}$/,
|
|
85
|
+
t: /^t-[1-9]\d{0,8}$/,
|
|
86
|
+
b: /^b-[1-9]\d{0,8}$/,
|
|
87
|
+
};
|
|
88
|
+
const CALL_REF = /^\$(0|[1-9]\d?)$/;
|
|
89
|
+
const TREE_HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
90
|
+
function idField(value, field, kind, allowCallRef = false) {
|
|
91
|
+
if (typeof value !== "string")
|
|
92
|
+
return fail(`${field} must be a string`);
|
|
93
|
+
const cleaned = value.trim();
|
|
94
|
+
if (ID_PATTERNS[kind].test(cleaned))
|
|
95
|
+
return { ok: true, value: cleaned };
|
|
96
|
+
if (allowCallRef && CALL_REF.test(cleaned))
|
|
97
|
+
return { ok: true, value: cleaned };
|
|
98
|
+
return fail(`${field} must look like ${kind}-7`);
|
|
99
|
+
}
|
|
100
|
+
/** The call-local index in `$n`, or null for a display ID. */
|
|
101
|
+
export function callRefIndex(id) {
|
|
102
|
+
const match = CALL_REF.exec(id);
|
|
103
|
+
return match ? Number(match[1]) : null;
|
|
104
|
+
}
|
|
105
|
+
const PLACEHOLDER_PREFIX = "FILL:";
|
|
106
|
+
function findPlaceholderIn(value, path) {
|
|
107
|
+
if (typeof value === "string")
|
|
108
|
+
return value.trim().startsWith(PLACEHOLDER_PREFIX) ? path : null;
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
111
|
+
const found = findPlaceholderIn(value[index], `${path}[${index}]`);
|
|
112
|
+
if (found)
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (isObject(value)) {
|
|
118
|
+
for (const [key, item] of Object.entries(value)) {
|
|
119
|
+
const found = findPlaceholderIn(item, `${path}.${key}`);
|
|
120
|
+
if (found)
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function parseKnown(type, value, field) {
|
|
127
|
+
const noExtra = (allowed) => {
|
|
128
|
+
const extra = unknownKey(value, allowed);
|
|
129
|
+
return extra ? `${field}.${extra} is not allowed` : null;
|
|
130
|
+
};
|
|
131
|
+
switch (type) {
|
|
132
|
+
case "decision.add": {
|
|
133
|
+
const extra = noExtra(["type", "text", "reason", "sourceRefs"]);
|
|
134
|
+
if (extra)
|
|
135
|
+
return fail(extra);
|
|
136
|
+
const text = shortText(value.text, `${field}.text`);
|
|
137
|
+
if (!text.ok)
|
|
138
|
+
return text;
|
|
139
|
+
const reason = shortText(value.reason, `${field}.reason`);
|
|
140
|
+
if (!reason.ok)
|
|
141
|
+
return reason;
|
|
142
|
+
const refs = optionalRefs(value.sourceRefs, `${field}.sourceRefs`);
|
|
143
|
+
if (!refs.ok)
|
|
144
|
+
return refs;
|
|
145
|
+
return { ok: true, value: { type, text: text.value, reason: reason.value, ...(refs.value ? { sourceRefs: refs.value } : {}) } };
|
|
146
|
+
}
|
|
147
|
+
case "decision.supersede": {
|
|
148
|
+
const extra = noExtra(["type", "id", "text", "reason", "sourceRefs"]);
|
|
149
|
+
if (extra)
|
|
150
|
+
return fail(extra);
|
|
151
|
+
const id = idField(value.id, `${field}.id`, "d");
|
|
152
|
+
if (!id.ok)
|
|
153
|
+
return id;
|
|
154
|
+
const text = shortText(value.text, `${field}.text`);
|
|
155
|
+
if (!text.ok)
|
|
156
|
+
return text;
|
|
157
|
+
const reason = shortText(value.reason, `${field}.reason`);
|
|
158
|
+
if (!reason.ok)
|
|
159
|
+
return reason;
|
|
160
|
+
const refs = optionalRefs(value.sourceRefs, `${field}.sourceRefs`);
|
|
161
|
+
if (!refs.ok)
|
|
162
|
+
return refs;
|
|
163
|
+
return { ok: true, value: { type, id: id.value, text: text.value, reason: reason.value, ...(refs.value ? { sourceRefs: refs.value } : {}) } };
|
|
164
|
+
}
|
|
165
|
+
case "decision.retire":
|
|
166
|
+
case "constraint.retire": {
|
|
167
|
+
const extra = noExtra(["type", "id", "reason"]);
|
|
168
|
+
if (extra)
|
|
169
|
+
return fail(extra);
|
|
170
|
+
const id = idField(value.id, `${field}.id`, type === "decision.retire" ? "d" : "c");
|
|
171
|
+
if (!id.ok)
|
|
172
|
+
return id;
|
|
173
|
+
const reason = shortText(value.reason, `${field}.reason`);
|
|
174
|
+
if (!reason.ok)
|
|
175
|
+
return reason;
|
|
176
|
+
return { ok: true, value: { type, id: id.value, reason: reason.value } };
|
|
177
|
+
}
|
|
178
|
+
case "decision.amend": {
|
|
179
|
+
const extra = noExtra(["type", "id", "reason"]);
|
|
180
|
+
if (extra)
|
|
181
|
+
return fail(extra);
|
|
182
|
+
const id = idField(value.id, `${field}.id`, "d");
|
|
183
|
+
if (!id.ok)
|
|
184
|
+
return id;
|
|
185
|
+
const reason = shortText(value.reason, `${field}.reason`);
|
|
186
|
+
if (!reason.ok)
|
|
187
|
+
return reason;
|
|
188
|
+
return { ok: true, value: { type, id: id.value, reason: reason.value } };
|
|
189
|
+
}
|
|
190
|
+
case "decision.approve": {
|
|
191
|
+
const extra = noExtra(["type", "id", "captureRef"]);
|
|
192
|
+
if (extra)
|
|
193
|
+
return fail(extra);
|
|
194
|
+
const id = idField(value.id, `${field}.id`, "d", true);
|
|
195
|
+
if (!id.ok)
|
|
196
|
+
return id;
|
|
197
|
+
const captureRef = shortText(value.captureRef, `${field}.captureRef`);
|
|
198
|
+
if (!captureRef.ok)
|
|
199
|
+
return captureRef;
|
|
200
|
+
return { ok: true, value: { type, id: id.value, captureRef: captureRef.value } };
|
|
201
|
+
}
|
|
202
|
+
case "constraint.add": {
|
|
203
|
+
const extra = noExtra(["type", "text", "sourceRefs"]);
|
|
204
|
+
if (extra)
|
|
205
|
+
return fail(extra);
|
|
206
|
+
const text = shortText(value.text, `${field}.text`);
|
|
207
|
+
if (!text.ok)
|
|
208
|
+
return text;
|
|
209
|
+
const refs = optionalRefs(value.sourceRefs, `${field}.sourceRefs`);
|
|
210
|
+
if (!refs.ok)
|
|
211
|
+
return refs;
|
|
212
|
+
return { ok: true, value: { type, text: text.value, ...(refs.value ? { sourceRefs: refs.value } : {}) } };
|
|
213
|
+
}
|
|
214
|
+
case "task.upsert": {
|
|
215
|
+
const extra = noExtra(["type", "id", "text", "status"]);
|
|
216
|
+
if (extra)
|
|
217
|
+
return fail(extra);
|
|
218
|
+
let id;
|
|
219
|
+
if (value.id !== undefined) {
|
|
220
|
+
const parsedId = idField(value.id, `${field}.id`, "t");
|
|
221
|
+
if (!parsedId.ok)
|
|
222
|
+
return parsedId;
|
|
223
|
+
id = parsedId.value;
|
|
224
|
+
}
|
|
225
|
+
const text = shortText(value.text, `${field}.text`);
|
|
226
|
+
if (!text.ok)
|
|
227
|
+
return text;
|
|
228
|
+
if (value.status !== "open" && value.status !== "unverified" && value.status !== "done") {
|
|
229
|
+
return fail(`${field}.status must be open, unverified, or done`);
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, value: { type, ...(id ? { id } : {}), text: text.value, status: value.status } };
|
|
232
|
+
}
|
|
233
|
+
case "task.drop":
|
|
234
|
+
case "blocker.resolve": {
|
|
235
|
+
const extra = noExtra(["type", "id"]);
|
|
236
|
+
if (extra)
|
|
237
|
+
return fail(extra);
|
|
238
|
+
const id = idField(value.id, `${field}.id`, type === "task.drop" ? "t" : "b");
|
|
239
|
+
if (!id.ok)
|
|
240
|
+
return id;
|
|
241
|
+
return { ok: true, value: { type, id: id.value } };
|
|
242
|
+
}
|
|
243
|
+
case "blocker.add": {
|
|
244
|
+
const extra = noExtra(["type", "text"]);
|
|
245
|
+
if (extra)
|
|
246
|
+
return fail(extra);
|
|
247
|
+
const text = shortText(value.text, `${field}.text`);
|
|
248
|
+
if (!text.ok)
|
|
249
|
+
return text;
|
|
250
|
+
return { ok: true, value: { type, text: text.value } };
|
|
251
|
+
}
|
|
252
|
+
case "evidence.record": {
|
|
253
|
+
const extra = noExtra(["type", "command", "exitCode", "tree", "capturedAt", "capturedBy"]);
|
|
254
|
+
if (extra)
|
|
255
|
+
return fail(extra);
|
|
256
|
+
const command = shortText(value.command, `${field}.command`, LEDGER_MAX_COMMAND);
|
|
257
|
+
if (!command.ok)
|
|
258
|
+
return command;
|
|
259
|
+
if (/[\r\n\u2028\u2029]/.test(command.value))
|
|
260
|
+
return fail(`${field}.command must be one line`);
|
|
261
|
+
if (typeof value.exitCode !== "number" || !Number.isInteger(value.exitCode) || value.exitCode < 0 || value.exitCode > 255) {
|
|
262
|
+
return fail(`${field}.exitCode must be a whole number from 0 to 255`);
|
|
263
|
+
}
|
|
264
|
+
if (typeof value.tree !== "string" || !TREE_HASH.test(value.tree)) {
|
|
265
|
+
return fail(`${field}.tree must be a 40 or 64 character lowercase hex hash`);
|
|
266
|
+
}
|
|
267
|
+
if (typeof value.capturedAt !== "number" || !Number.isInteger(value.capturedAt) || value.capturedAt < 1) {
|
|
268
|
+
return fail(`${field}.capturedAt must be a positive whole number of milliseconds`);
|
|
269
|
+
}
|
|
270
|
+
if (value.capturedBy !== undefined && value.capturedBy !== "agent") {
|
|
271
|
+
return fail(`${field}.capturedBy must be agent`);
|
|
272
|
+
}
|
|
273
|
+
return { ok: true, value: { type, command: command.value, exitCode: value.exitCode, tree: value.tree, capturedAt: value.capturedAt } };
|
|
274
|
+
}
|
|
275
|
+
case "seal": {
|
|
276
|
+
const extra = noExtra(["type", "goal", "nextAction"]);
|
|
277
|
+
if (extra)
|
|
278
|
+
return fail(extra);
|
|
279
|
+
let goal;
|
|
280
|
+
if (value.goal !== undefined) {
|
|
281
|
+
const parsedGoal = shortText(value.goal, `${field}.goal`, LEDGER_MAX_GOAL);
|
|
282
|
+
if (!parsedGoal.ok)
|
|
283
|
+
return parsedGoal;
|
|
284
|
+
goal = parsedGoal.value;
|
|
285
|
+
}
|
|
286
|
+
const nextAction = shortText(value.nextAction, `${field}.nextAction`);
|
|
287
|
+
if (!nextAction.ok)
|
|
288
|
+
return nextAction;
|
|
289
|
+
return { ok: true, value: { type, ...(goal !== undefined ? { goal } : {}), nextAction: nextAction.value } };
|
|
290
|
+
}
|
|
291
|
+
default:
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Check one op. A known type is checked field by field and rebuilt from its named fields, so a typo
|
|
297
|
+
* or a smuggled field is refused by name. An unknown type is kept as sent (`unsupported`, bounded in
|
|
298
|
+
* size). Every op, known or not, goes through the secret scan and the `FILL:` guard.
|
|
299
|
+
*/
|
|
300
|
+
export function parseLedgerOp(value, field = "op") {
|
|
301
|
+
if (!isObject(value))
|
|
302
|
+
return fail(`${field} must be an object`);
|
|
303
|
+
const type = value.type;
|
|
304
|
+
if (typeof type !== "string" || !type.trim() || type.length > MAX_TYPE)
|
|
305
|
+
return fail(`${field}.type is required`);
|
|
306
|
+
const known = parseKnown(type, value, field);
|
|
307
|
+
let result;
|
|
308
|
+
if (known) {
|
|
309
|
+
if (!known.ok)
|
|
310
|
+
return known;
|
|
311
|
+
result = { kind: "known", op: known.value };
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
let size;
|
|
315
|
+
try {
|
|
316
|
+
size = JSON.stringify(value).length;
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return fail(`${field} must be plain JSON`);
|
|
320
|
+
}
|
|
321
|
+
if (size > MAX_UNSUPPORTED_JSON)
|
|
322
|
+
return fail(`${field} is too large`);
|
|
323
|
+
result = { kind: "unsupported", op: value };
|
|
324
|
+
}
|
|
325
|
+
const placeholder = findPlaceholderIn(result.op, field);
|
|
326
|
+
if (placeholder)
|
|
327
|
+
return fail(`${placeholder} still contains a FILL: placeholder`);
|
|
328
|
+
// Saved text reaches every later session, so a secret is refused, never stored.
|
|
329
|
+
const secret = findSecretInValue(result.op, field);
|
|
330
|
+
if (secret)
|
|
331
|
+
return fail(`${secret.field} looks like ${secret.shape}. Remove it; Hypher never stores secrets.`);
|
|
332
|
+
return { ok: true, value: result };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Check a whole call. One malformed known op refuses the call; nothing is partly applied. Known ops
|
|
336
|
+
* come back rebuilt from their named fields, unknown ones as sent.
|
|
337
|
+
*/
|
|
338
|
+
export function parseLedgerCall(value) {
|
|
339
|
+
if (!isObject(value))
|
|
340
|
+
return fail("call must be an object");
|
|
341
|
+
const extra = unknownKey(value, ["baseRevision", "idempotencyKey", "repo", "ops"]);
|
|
342
|
+
if (extra)
|
|
343
|
+
return fail(`call.${extra} is not allowed`);
|
|
344
|
+
const base = value.baseRevision;
|
|
345
|
+
if (typeof base !== "number" || !Number.isInteger(base) || base < 0 || base > 1_000_000) {
|
|
346
|
+
return fail("baseRevision must be a non-negative integer");
|
|
347
|
+
}
|
|
348
|
+
let idempotencyKey;
|
|
349
|
+
if (value.idempotencyKey !== undefined) {
|
|
350
|
+
const key = shortText(value.idempotencyKey, "idempotencyKey", MAX_KEY);
|
|
351
|
+
if (!key.ok)
|
|
352
|
+
return key;
|
|
353
|
+
idempotencyKey = key.value;
|
|
354
|
+
}
|
|
355
|
+
const repo = parseRepoSnapshot(value.repo);
|
|
356
|
+
if (!repo.ok)
|
|
357
|
+
return repo;
|
|
358
|
+
const repoSecret = findSecretInValue(repo.value, "repo");
|
|
359
|
+
if (repoSecret)
|
|
360
|
+
return fail(`${repoSecret.field} looks like ${repoSecret.shape}. Remove it; Hypher never stores secrets.`);
|
|
361
|
+
if (!Array.isArray(value.ops))
|
|
362
|
+
return fail("ops must be an array");
|
|
363
|
+
if (value.ops.length === 0)
|
|
364
|
+
return fail("ops is required");
|
|
365
|
+
if (value.ops.length > LEDGER_MAX_OPS_PER_CALL) {
|
|
366
|
+
return fail(`A call carries at most ${LEDGER_MAX_OPS_PER_CALL} ops; this one has ${value.ops.length}.`);
|
|
367
|
+
}
|
|
368
|
+
const ops = [];
|
|
369
|
+
for (let index = 0; index < value.ops.length; index += 1) {
|
|
370
|
+
const parsed = parseLedgerOp(value.ops[index], `ops[${index}]`);
|
|
371
|
+
if (!parsed.ok)
|
|
372
|
+
return parsed;
|
|
373
|
+
ops.push(parsed.value.op);
|
|
374
|
+
}
|
|
375
|
+
return { ok: true, value: { baseRevision: base, ...(idempotencyKey ? { idempotencyKey } : {}), repo: repo.value, ops } };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* A hash of the body of a call: the revision it rebases from, the branch, and the ops. The same key
|
|
379
|
+
* with a different hash is refused; the same key with the same hash is a retry.
|
|
380
|
+
*/
|
|
381
|
+
export function ledgerCallContentHash(call) {
|
|
382
|
+
return sha256Hex(canonicalJson({ baseRevision: call.baseRevision, branch: call.repo.branch, ops: call.ops }));
|
|
383
|
+
}
|
|
384
|
+
/** The key for a call that did not name one: a hash of the base revision and the ops, as the CLI does for v1. */
|
|
385
|
+
export function ledgerCallKey(call) {
|
|
386
|
+
return call.idempotencyKey ?? defaultIdempotencyKey(call.baseRevision, { branch: call.repo.branch, ops: call.ops });
|
|
387
|
+
}
|
|
@@ -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
|
+
}
|