@litterthanlit/hypher-cli 0.1.6 → 0.1.8
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 +1 -0
- package/package.json +1 -1
- package/src/cli.mjs +3 -1
- package/src/commands/mcp.mjs +15 -1
- package/src/commands/seal.mjs +29 -2
- package/src/env.mjs +1 -1
- package/src/hooks/index.mjs +2 -2
- package/src/session.mjs +73 -29
- package/vendor/handoffNote.mjs +25 -0
- package/vendor/handoffSeal.mjs +81 -3
- package/vendor/structuredHandoff.mjs +333 -0
package/README.md
CHANGED
|
@@ -5,6 +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.
|
|
7
7
|
- `hypher resume` prints the note for this repository; `hypher seal` saves a handoff.
|
|
8
|
+
- `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.
|
|
8
9
|
- `hypher mcp` is the bridge for any MCP client: `resume`, `seal`, and `acknowledge`, with no IDs.
|
|
9
10
|
- `hypher uninstall` removes exactly what `init` added and revokes the key.
|
|
10
11
|
- `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.
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -35,6 +35,7 @@ const OPTIONS = {
|
|
|
35
35
|
completed: { type: "string", multiple: true },
|
|
36
36
|
unverified: { type: "string", multiple: true },
|
|
37
37
|
blocker: { type: "string", multiple: true },
|
|
38
|
+
evidence: { type: "string", multiple: true },
|
|
38
39
|
source: { type: "string" },
|
|
39
40
|
agent: { type: "string" },
|
|
40
41
|
session: { type: "string" },
|
|
@@ -52,7 +53,8 @@ Usage: hypher <command>
|
|
|
52
53
|
doctor Check every piece and print the fix for anything red
|
|
53
54
|
status What is installed; --last shows exactly what was last sent
|
|
54
55
|
resume Print the handoff note for this repository and its truth check
|
|
55
|
-
seal Save a handoff: --next "…" [--decision "…" --why "…" [--replaces "…"]]
|
|
56
|
+
seal Save a handoff: --next "…" [--decision "…" --why "…" [--replaces "…"]]
|
|
57
|
+
[--evidence "<command>=<exit code>": a check you ran] or JSON on stdin
|
|
56
58
|
ack Acknowledge the handoff this session read
|
|
57
59
|
mcp Run the stdio MCP bridge: resume, seal, acknowledge (for any MCP client)
|
|
58
60
|
uninstall Remove exactly what init added and revoke the key
|
package/src/commands/mcp.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { createClient, HypherRejected, HypherUnavailable } from "../api.mjs";
|
|
|
14
14
|
import { CLI_VERSION } from "../env.mjs";
|
|
15
15
|
import { detectAgent, resumeHere, sealHere } from "../session.mjs";
|
|
16
16
|
import { acknowledgeHere } from "./ack.mjs";
|
|
17
|
+
import { evidenceInput } from "./seal.mjs";
|
|
17
18
|
|
|
18
19
|
const SUPPORTED_PROTOCOLS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"];
|
|
19
20
|
|
|
@@ -40,7 +41,7 @@ export const LOCAL_TOOLS = [
|
|
|
40
41
|
{
|
|
41
42
|
name: "seal",
|
|
42
43
|
title: "Save to Hypher",
|
|
43
|
-
description: "Call before you finish work that changed files, or when asked to save. Saves a handoff for the next agent from what you know: the decisions that changed and why, and the next action. The bridge adds repository, branch, commit, and tree identity and carries earlier decisions forward. Sends only this text, file names, and commit identity; never code. Needs no IDs. The first save in a repository also needs goal, and so does a save after the work moved on from the note's goal.",
|
|
44
|
+
description: "Call before you finish work that changed files, or when asked to save. Saves a handoff for the next agent from what you know: the decisions that changed and why, and the next action. The bridge adds repository, branch, commit, and tree identity and carries earlier decisions forward. To report checks, pass evidence: only commands you actually ran, with their exit codes. Hypher records them against the current tree as an agent report; it never runs them. Sends only this text, file names, and commit identity; never code or command output. Needs no IDs. The first save in a repository also needs goal, and so does a save after the work moved on from the note's goal.",
|
|
44
45
|
inputSchema: {
|
|
45
46
|
type: "object",
|
|
46
47
|
properties: {
|
|
@@ -63,6 +64,18 @@ export const LOCAL_TOOLS = [
|
|
|
63
64
|
completed: LIST("Work you finished (reported, not verified)."),
|
|
64
65
|
unverified: LIST("Work that still needs checking."),
|
|
65
66
|
blockers: LIST("What blocks the next action, if anything."),
|
|
67
|
+
evidence: {
|
|
68
|
+
type: "array",
|
|
69
|
+
description: "Checks you ran in this session: report only commands you actually ran, with their exit codes. Hypher records them against the current tree as an agent report and never runs them. The command text only, never its output. A check against an older tree is flagged for the next agent. At most 8.",
|
|
70
|
+
items: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
command: { type: "string", description: "The command as you ran it, on one line." },
|
|
74
|
+
exitCode: { type: "integer", minimum: 0, maximum: 255, description: "Its exit code." },
|
|
75
|
+
},
|
|
76
|
+
required: ["command", "exitCode"],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
66
79
|
cwd: CWD_PROPERTY,
|
|
67
80
|
},
|
|
68
81
|
required: ["nextAction"],
|
|
@@ -171,6 +184,7 @@ export async function callLocalTool(name, args, session) {
|
|
|
171
184
|
completed: asList(args?.completed),
|
|
172
185
|
unverified: asList(args?.unverified),
|
|
173
186
|
blockers: asList(args?.blockers),
|
|
187
|
+
evidence: evidenceInput(args?.evidence),
|
|
174
188
|
};
|
|
175
189
|
const result = await sealHere({ env: session.env, cwd, seal, agent });
|
|
176
190
|
return textResult(result.message, !result.ok);
|
package/src/commands/seal.mjs
CHANGED
|
@@ -5,15 +5,37 @@
|
|
|
5
5
|
*
|
|
6
6
|
* hypher seal --next "Fix rounding" --decision "Guest checkout only" --why "…" --replaces D1
|
|
7
7
|
* hypher seal '{"nextAction":"…","decisions":[{"decision":"…","why":"…"}]}'
|
|
8
|
+
* hypher seal --next "Fix rounding" --evidence "npm test=0" --evidence "npm run lint=1"
|
|
8
9
|
* echo '{"nextAction":"…"}' | hypher seal --stdin
|
|
9
10
|
*
|
|
10
11
|
* The JSON fields are the MCP `seal` tool's: nextAction, goal, decisions
|
|
11
|
-
* [{ decision, why, replaces }], constraints, completed, unverified, blockers
|
|
12
|
+
* [{ decision, why, replaces }], constraints, completed, unverified, blockers,
|
|
13
|
+
* evidence [{ command, exitCode }]: the checks the agent ran. Hypher records each
|
|
14
|
+
* against the current tree as an agent report; it never runs the command.
|
|
12
15
|
*/
|
|
13
16
|
import { detectAgent, sealHere } from "../session.mjs";
|
|
14
17
|
import { readWorktreeState } from "../state.mjs";
|
|
15
18
|
import { repoIdentity } from "../../vendor/hypher-git.mjs";
|
|
16
19
|
|
|
20
|
+
/** `--evidence "<command>=<exit code>"`: split at the last `=`, so a command may hold its own. */
|
|
21
|
+
export function parseEvidenceFlag(value) {
|
|
22
|
+
const text = String(value ?? "");
|
|
23
|
+
const at = text.lastIndexOf("=");
|
|
24
|
+
const command = at < 0 ? "" : text.slice(0, at).trim();
|
|
25
|
+
const code = at < 0 ? "" : text.slice(at + 1).trim();
|
|
26
|
+
if (!command) return { error: `--evidence "${text}" needs the form "<command>=<exit code>", for example --evidence "npm test=0".` };
|
|
27
|
+
if (!/^\d{1,3}$/.test(code) || Number(code) > 255) {
|
|
28
|
+
return { error: `--evidence "${command}": the exit code "${code}" must be a whole number from 0 to 255.` };
|
|
29
|
+
}
|
|
30
|
+
return { evidence: { command, exitCode: Number(code) } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The evidence array from JSON or MCP arguments: only `command` and `exitCode` are read. */
|
|
34
|
+
export function evidenceInput(value) {
|
|
35
|
+
if (!Array.isArray(value)) return value;
|
|
36
|
+
return value.map((item) => ({ command: item?.command, exitCode: item?.exitCode }));
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/** Pair each --why and --replaces with the --decision before it. */
|
|
18
40
|
export function sealFromTokens(tokens) {
|
|
19
41
|
const seal = { decisions: [] };
|
|
@@ -23,7 +45,11 @@ export function sealFromTokens(tokens) {
|
|
|
23
45
|
const value = token.value ?? "";
|
|
24
46
|
if (token.name === "goal") seal.goal = value;
|
|
25
47
|
else if (token.name === "next") seal.nextAction = value;
|
|
26
|
-
else if (token.name === "
|
|
48
|
+
else if (token.name === "evidence") {
|
|
49
|
+
const parsed = parseEvidenceFlag(value);
|
|
50
|
+
if (parsed.error) return { error: parsed.error };
|
|
51
|
+
(seal.evidence ??= []).push(parsed.evidence);
|
|
52
|
+
} else if (token.name === "decision") seal.decisions.push({ decision: value, why: "" });
|
|
27
53
|
else if (token.name === "why" || token.name === "replaces") {
|
|
28
54
|
const last = seal.decisions[seal.decisions.length - 1];
|
|
29
55
|
if (!last) return { error: `--${token.name} must follow a --decision.` };
|
|
@@ -55,6 +81,7 @@ export function sealFromJson(parsed) {
|
|
|
55
81
|
completed: list(parsed.completed),
|
|
56
82
|
unverified: list(parsed.unverified),
|
|
57
83
|
blockers: list(parsed.blockers),
|
|
84
|
+
evidence: evidenceInput(parsed.evidence),
|
|
58
85
|
},
|
|
59
86
|
};
|
|
60
87
|
}
|
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.1.
|
|
10
|
+
export const CLI_VERSION = "0.1.8";
|
|
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/hooks/index.mjs
CHANGED
|
@@ -52,10 +52,10 @@ function sealRequest(firstHandoff) {
|
|
|
52
52
|
const goal = firstHandoff ? ' --goal "<the goal>"' : "";
|
|
53
53
|
return [
|
|
54
54
|
"Hypher: the working tree changed since the last handoff, and no one saved why.",
|
|
55
|
-
"Before you stop, record the decisions that changed
|
|
55
|
+
"Before you stop, record the decisions that changed, the next action for the next agent, and the checks you ran with their exit codes:",
|
|
56
56
|
`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.`,
|
|
57
57
|
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 \"…\").",
|
|
58
|
-
"Add one --decision/--why pair per changed decision (--replaces D1 names one it replaces), or none if no decision changed; --next is always needed.",
|
|
58
|
+
"Add one --decision/--why pair per changed decision (--replaces D1 names one it replaces), or none if no decision changed; --next is always needed. Report only checks you ran (evidence, or --evidence \"<command>=<exit code>\").",
|
|
59
59
|
"It sends only your text, file names, and commit identity. Hypher asks once; if you cannot save now, say so and stop.",
|
|
60
60
|
].join(" ");
|
|
61
61
|
}
|
package/src/session.mjs
CHANGED
|
@@ -17,13 +17,18 @@ import {
|
|
|
17
17
|
renderNoHandoffNote,
|
|
18
18
|
renderUnlinkedLine,
|
|
19
19
|
} from "../vendor/handoffNote.mjs";
|
|
20
|
-
import { buildSealProposal } from "../vendor/handoffSeal.mjs";
|
|
21
|
-
import {
|
|
20
|
+
import { buildSealProposal, CLI_UPDATE_COMMAND, readOnlyHandoffMessage } from "../vendor/handoffSeal.mjs";
|
|
21
|
+
import { parseHandoffProposalLenient } from "../vendor/structuredHandoff.mjs";
|
|
22
22
|
|
|
23
23
|
// The MCP tool first: in an agent's shell `hypher` may be off PATH or inside Codex's sandbox.
|
|
24
24
|
export const ACK_HINT = "call the `acknowledge` tool of the `hypher` MCP server, or run `hypher ack`";
|
|
25
25
|
const NOT_CONNECTED_LINE = "Hypher is not connected on this computer; starting without it. Run `hypher init` in a terminal.";
|
|
26
26
|
|
|
27
|
+
/** Said at the top of the note when the handoff is newer than this copy of Hypher can save over. */
|
|
28
|
+
export function readOnlyNotice(version) {
|
|
29
|
+
return `Hypher: this handoff is read-only here. It is format version ${version}, newer than this copy of Hypher can save over, so a save would be refused and nothing would be sent. Use the note as it is. To save again, run \`${CLI_UPDATE_COMMAND}\` in a terminal.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
/** The agent running this command, from its environment. */
|
|
28
33
|
export function detectAgent(env, fallback) {
|
|
29
34
|
if (env.HYPHER_AGENT) return String(env.HYPHER_AGENT).slice(0, 40);
|
|
@@ -104,8 +109,9 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
|
|
|
104
109
|
return { status: "rejected", note: `Hypher: ${prepared?.error ?? "could not load the handoff"}. Run \`hypher doctor\`.`, snapshot };
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
// Read leniently: a field, a longer list, or a newer version written by a newer Hypher is not a reason to refuse the note.
|
|
113
|
+
const parsed = parseHandoffProposalLenient(prepared.proposal);
|
|
114
|
+
if (!parsed.ok) return { status: "rejected", note: `Hypher returned a handoff this CLI cannot read. Update with \`${CLI_UPDATE_COMMAND}\`.`, snapshot };
|
|
109
115
|
const proposal = parsed.value;
|
|
110
116
|
const verdict = truthCheck(snapshot.root, proposal.repo);
|
|
111
117
|
const savedAt = typeof prepared.savedAt === "number" ? prepared.savedAt : undefined;
|
|
@@ -117,8 +123,11 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
|
|
|
117
123
|
from: prepared.sourceAgent,
|
|
118
124
|
savedAt,
|
|
119
125
|
verdict,
|
|
126
|
+
// Each reported check says whether its tree is the tree now.
|
|
127
|
+
currentTree: verdict?.current?.tree ?? snapshot.tree,
|
|
120
128
|
checkpoint: shownCheckpoint,
|
|
121
129
|
acknowledge: ACK_HINT,
|
|
130
|
+
notice: parsed.readOnly ? readOnlyNotice(proposal.schemaVersion) : null,
|
|
122
131
|
});
|
|
123
132
|
updateWorktreeState(env, snapshot.root, (state) => withBranchState({
|
|
124
133
|
...state,
|
|
@@ -141,9 +150,19 @@ export async function resumeHere({ env, cwd, destination, sessionId, now = Date.
|
|
|
141
150
|
};
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
/** Said once when an older server refuses the `evidence` field and the handoff goes without it. */
|
|
154
|
+
export const NO_EVIDENCE_LINE = "This Hypher server does not accept evidence yet; the handoff was saved without it.";
|
|
155
|
+
|
|
156
|
+
/** True when a refusal is the server rejecting the `evidence` field itself, not a bad entry. */
|
|
157
|
+
export function refusesEvidenceField(text) {
|
|
158
|
+
const message = String(text ?? "");
|
|
159
|
+
return /\bevidence\b/i.test(message) && /not allowed|extra field|not in the validator|unknown (?:field|key)|unexpected/i.test(message);
|
|
160
|
+
}
|
|
161
|
+
|
|
144
162
|
/**
|
|
145
163
|
* Save a handoff from what the agent knows: decisions that changed and why,
|
|
146
|
-
*
|
|
164
|
+
* the next action, and the checks it ran (command and exit code, bound to the
|
|
165
|
+
* tree at this save). Based on the revision this session was handed.
|
|
147
166
|
* @param {{ env: NodeJS.ProcessEnv, cwd: string, seal: object, agent: string, now?: number }} options
|
|
148
167
|
*/
|
|
149
168
|
export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
|
|
@@ -162,13 +181,25 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
|
|
|
162
181
|
if (!state || state.projectId !== link.projectId || typeof state.baseRevision !== "number") {
|
|
163
182
|
return { ok: false, code: "no-base", message: "Run `hypher resume` first, so this save is based on the handoff you read." };
|
|
164
183
|
}
|
|
165
|
-
const prior = state.proposal ?
|
|
184
|
+
const prior = state.proposal ? parseHandoffProposalLenient(state.proposal) : null;
|
|
185
|
+
// A newer format than this copy can write: refused before anything is built or sent.
|
|
186
|
+
if (prior?.ok && prior.readOnly) return { ok: false, code: "read-only", message: readOnlyHandoffMessage(prior.value.schemaVersion) };
|
|
166
187
|
const iso = new Date(now).toISOString().slice(0, 16).replace("T", " ");
|
|
188
|
+
// Checks are bound to the working-tree hash at this save. Without one, the handoff still
|
|
189
|
+
// saves; the checks are left out and the agent is told.
|
|
190
|
+
const lines = [];
|
|
191
|
+
let toSeal = seal;
|
|
192
|
+
if (Array.isArray(seal.evidence) && seal.evidence.length && !snapshot.tree) {
|
|
193
|
+
toSeal = { ...seal, evidence: undefined };
|
|
194
|
+
lines.push("The checks were not recorded: Hypher could not read the working tree hash.");
|
|
195
|
+
}
|
|
167
196
|
const built = buildSealProposal({
|
|
168
197
|
prior: prior?.ok ? prior.value : null,
|
|
169
|
-
seal,
|
|
198
|
+
seal: toSeal,
|
|
170
199
|
repo: snapshot.currentRepo,
|
|
171
200
|
sessionRef: `${agent} session on ${snapshot.branch}, ${iso} UTC`,
|
|
201
|
+
tree: snapshot.tree,
|
|
202
|
+
now,
|
|
172
203
|
});
|
|
173
204
|
if (!built.ok) return { ok: false, code: "invalid", message: built.error };
|
|
174
205
|
|
|
@@ -178,28 +209,41 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
|
|
|
178
209
|
? { ok: false, code: "sandboxed", message: SANDBOX_LINE }
|
|
179
210
|
: { ok: false, code: "not-connected", message: "This computer is not connected. Run `hypher init`." };
|
|
180
211
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
212
|
+
/** One save request. Returns the server's answer, or the failure to hand back. */
|
|
213
|
+
async function send(proposal) {
|
|
214
|
+
const request = {
|
|
215
|
+
kind: "handoff",
|
|
216
|
+
source: agent,
|
|
217
|
+
projectId: link.projectId,
|
|
218
|
+
proposal,
|
|
219
|
+
expectedBaseRevision: state.baseRevision,
|
|
220
|
+
};
|
|
221
|
+
// Same base and same request: same key, so a retry dedupes. A change that
|
|
222
|
+
// returns to earlier content has a newer base, so it is not a duplicate.
|
|
223
|
+
const idempotencyKey = `seal-${sha256(`${state.baseRevision}\n${canonicalJson(request)}`).slice(0, 40)}`;
|
|
224
|
+
try {
|
|
225
|
+
return { result: (await client.tool("post_agent_event", { ...request, idempotencyKey })).structured };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof HypherUnavailable) {
|
|
228
|
+
return { failure: inCodexSandbox(env)
|
|
229
|
+
? { ok: false, code: "sandboxed", message: SANDBOX_LINE }
|
|
230
|
+
: { ok: false, code: "unavailable", message: "Hypher unavailable; nothing was saved. Run `hypher doctor`, then seal again." } };
|
|
231
|
+
}
|
|
232
|
+
if (error instanceof HypherRejected) return { failure: { ok: false, code: "rejected", message: error.message } };
|
|
233
|
+
throw error;
|
|
199
234
|
}
|
|
200
|
-
if (error instanceof HypherRejected) return { ok: false, code: "rejected", message: error.message };
|
|
201
|
-
throw error;
|
|
202
235
|
}
|
|
236
|
+
let saved = built.proposal;
|
|
237
|
+
let sent = await send(saved);
|
|
238
|
+
// A server that predates evidence refuses the field itself. Once, without it.
|
|
239
|
+
if (saved.evidence && (refusesEvidenceField(sent.failure?.message) || (sent.result?.ok === false && refusesEvidenceField(sent.result.error)))) {
|
|
240
|
+
const { evidence: _evidence, ...withoutEvidence } = saved;
|
|
241
|
+
saved = withoutEvidence;
|
|
242
|
+
sent = await send(saved);
|
|
243
|
+
lines.push(NO_EVIDENCE_LINE);
|
|
244
|
+
}
|
|
245
|
+
if (sent.failure) return sent.failure;
|
|
246
|
+
const result = sent.result;
|
|
203
247
|
if (!result?.ok) {
|
|
204
248
|
if (result?.code === "stale-revision") {
|
|
205
249
|
return { ok: false, code: "stale", message: `Hypher has a newer handoff (revision ${result.headRevision}) than the one this session read (revision ${state.baseRevision}). Run \`hypher resume\`, read it, and seal again.` };
|
|
@@ -211,7 +255,7 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
|
|
|
211
255
|
updateWorktreeState(env, snapshot.root, (current) => withBranchState({
|
|
212
256
|
...current,
|
|
213
257
|
baseRevision: result.revision,
|
|
214
|
-
proposal:
|
|
258
|
+
proposal: saved,
|
|
215
259
|
agent,
|
|
216
260
|
}, snapshot.branch, { sealTree: tree }));
|
|
217
261
|
return {
|
|
@@ -219,7 +263,7 @@ export async function sealHere({ env, cwd, seal, agent, now = Date.now() }) {
|
|
|
219
263
|
code: result.code,
|
|
220
264
|
revision: result.revision,
|
|
221
265
|
projectName: link.projectName,
|
|
222
|
-
message: `Saved handoff revision ${result.revision} for "${link.projectName}". Memory did not transfer code.`,
|
|
266
|
+
message: [`Saved handoff revision ${result.revision} for "${link.projectName}". Memory did not transfer code.`, ...lines].join("\n"),
|
|
223
267
|
};
|
|
224
268
|
}
|
|
225
269
|
|
package/vendor/handoffNote.mjs
CHANGED
|
@@ -89,6 +89,27 @@ export function describeCheckpoint(checkpoint, now) {
|
|
|
89
89
|
const files = checkpoint.changedFiles.length ? `changed: ${fileList(checkpoint.changedFiles)}` : "no uncommitted changes";
|
|
90
90
|
return `Last checkpoint: ${formatAge(now - checkpoint.capturedAt)} ago from ${noteText(checkpoint.source, 40)} (${noteText(checkpoint.event, 40)}), not sealed · branch ${noteText(checkpoint.branch, 200)} · ${files}.`;
|
|
91
91
|
}
|
|
92
|
+
/** A code span that survives backticks inside the text. */
|
|
93
|
+
function codeSpan(text) {
|
|
94
|
+
const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
|
95
|
+
const fence = "`".repeat(longest + 1);
|
|
96
|
+
return longest > 0 ? `${fence} ${text} ${fence}` : `${fence}${text}${fence}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One reported check: the command, its exit code, who reported it, how old it is, and
|
|
100
|
+
* whether the tree it ran against is the tree now. Hypher never runs the command; a check
|
|
101
|
+
* against an older tree says to run it again.
|
|
102
|
+
*/
|
|
103
|
+
export function describeEvidence(item, now, currentTree, max = 280) {
|
|
104
|
+
// The validator refuses `hook` until Baton M3; the wording waits for it.
|
|
105
|
+
const who = item.capturedBy === "hook" ? "captured by a hook" : "agent report";
|
|
106
|
+
const tree = !currentTree
|
|
107
|
+
? "tree not checked here"
|
|
108
|
+
: currentTree === item.tree
|
|
109
|
+
? "same tree as now"
|
|
110
|
+
: "the tree changed since; run it again before relying on it";
|
|
111
|
+
return `${codeSpan(noteText(item.command, max))} exit ${item.exitCode}, ${who}, ${formatAge(now - item.capturedAt)} ago, ${tree}.`;
|
|
112
|
+
}
|
|
92
113
|
function joinItems(items, empty, max = 280) {
|
|
93
114
|
const cleaned = items.map((item) => noteText(item, max)).filter(Boolean);
|
|
94
115
|
return cleaned.length ? cleaned.map((item) => (/[.!?…]$/.test(item) ? item : `${item}.`)).join(" ") : empty;
|
|
@@ -114,6 +135,7 @@ function sections(input, max, listLimit) {
|
|
|
114
135
|
header,
|
|
115
136
|
NOTE_FIRST_REPLY,
|
|
116
137
|
NOTE_PREAMBLE,
|
|
138
|
+
...(input.notice ? [noteText(input.notice, 500)] : []),
|
|
117
139
|
input.verdict ? describeVerdict(input.verdict)
|
|
118
140
|
: "Tree: not checked here. Compare the repository, branch, commit, and uncommitted files yourself before continuing.",
|
|
119
141
|
`Constraints: ${joinItems(limit(proposal.constraints), "none recorded.", max)}${more(proposal.constraints)}`,
|
|
@@ -122,6 +144,9 @@ function sections(input, max, listLimit) {
|
|
|
122
144
|
`Completed (agent-reported, not verified): ${joinItems(limit(proposal.completed), "none.", max)}${more(proposal.completed)}`,
|
|
123
145
|
`Unverified: ${joinItems(limit(proposal.unverified), "none.", max)}${more(proposal.unverified)}`,
|
|
124
146
|
`Blockers: ${joinItems(limit(proposal.blockers), "none.", max)}${more(proposal.blockers)}`,
|
|
147
|
+
...(proposal.evidence?.length
|
|
148
|
+
? [`Evidence: ${limit(proposal.evidence).map((item) => describeEvidence(item, input.now, input.currentTree, max)).join(" ")}${more(proposal.evidence)}`]
|
|
149
|
+
: []),
|
|
125
150
|
...(input.checkpoint ? [describeCheckpoint(input.checkpoint, input.now)] : []),
|
|
126
151
|
`Sources: ${limit(proposal.sources).map((source) => {
|
|
127
152
|
const kind = source.kind === "capture" ? "project capture" : source.kind === "agent_event" ? "stored agent report" : "agent report";
|
package/vendor/handoffSeal.mjs
CHANGED
|
@@ -6,8 +6,17 @@
|
|
|
6
6
|
* adds a source for this session, and maps short decision references ("D2",
|
|
7
7
|
* or a unique part of the text) to the exact prior text for `supersedes`.
|
|
8
8
|
*/
|
|
9
|
-
import { HANDOFF_PROPOSAL_SCHEMA_VERSION, parseHandoffProposal, } from "./structuredHandoff.mjs";
|
|
9
|
+
import { HANDOFF_PROPOSAL_SCHEMA_VERSION, knownFieldsV1, parseHandoffProposal, reattachUnknownFields, } from "./structuredHandoff.mjs";
|
|
10
10
|
export const MAX_HANDOFF_ITEMS = 8;
|
|
11
|
+
/**
|
|
12
|
+
* What an installed copy runs to update itself. `hypher init` from an installed copy finds itself
|
|
13
|
+
* already at its own path and keeps the same version; the `@latest` fetch is what updates.
|
|
14
|
+
*/
|
|
15
|
+
export const CLI_UPDATE_COMMAND = "npx @litterthanlit/hypher-cli@latest init";
|
|
16
|
+
/** Why a save on top of a newer-format handoff is refused. One line; nothing was sent. */
|
|
17
|
+
export function readOnlyHandoffMessage(version) {
|
|
18
|
+
return `This handoff is format version ${version}, newer than this copy of Hypher can save over, so nothing was sent. Update with \`${CLI_UPDATE_COMMAND}\` in a terminal, then save again.`;
|
|
19
|
+
}
|
|
11
20
|
function clean(value) {
|
|
12
21
|
return typeof value === "string" ? value.trim() : "";
|
|
13
22
|
}
|
|
@@ -59,15 +68,67 @@ function mergeUnique(prior, added) {
|
|
|
59
68
|
}
|
|
60
69
|
return merged;
|
|
61
70
|
}
|
|
71
|
+
const MAX_EVIDENCE_COMMAND = 300;
|
|
72
|
+
const TREE_HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
73
|
+
/**
|
|
74
|
+
* The checks this save reports, bound to the tree at the save, as agent
|
|
75
|
+
* reports. The order given is kept; a command listed twice keeps its last entry.
|
|
76
|
+
*/
|
|
77
|
+
function evidenceFromSeal(raw, tree, now) {
|
|
78
|
+
if (raw === undefined || raw === null)
|
|
79
|
+
return { ok: true, items: [] };
|
|
80
|
+
if (!Array.isArray(raw))
|
|
81
|
+
return { ok: false, error: "evidence must be a list of { command, exitCode }." };
|
|
82
|
+
const reported = [];
|
|
83
|
+
for (const item of raw) {
|
|
84
|
+
const entry = (item ?? {});
|
|
85
|
+
const command = clean(entry.command);
|
|
86
|
+
if (!command)
|
|
87
|
+
return { ok: false, error: "Each evidence entry needs a command (in a shell, --evidence \"<command>=<exit code>\")." };
|
|
88
|
+
if (/[\r\n\u2028\u2029]/.test(command))
|
|
89
|
+
return { ok: false, error: `Evidence command "${command.replace(/\s+/g, " ").slice(0, 40)}…" must be one line.` };
|
|
90
|
+
if (command.length > MAX_EVIDENCE_COMMAND) {
|
|
91
|
+
return { ok: false, error: `Evidence command "${command.slice(0, 40)}…" is ${command.length} characters; the limit is ${MAX_EVIDENCE_COMMAND}.` };
|
|
92
|
+
}
|
|
93
|
+
const exitCode = entry.exitCode;
|
|
94
|
+
if (typeof exitCode !== "number" || !Number.isInteger(exitCode) || exitCode < 0 || exitCode > 255) {
|
|
95
|
+
return { ok: false, error: `The exit code for evidence "${command}" must be a whole number from 0 to 255.` };
|
|
96
|
+
}
|
|
97
|
+
reported.push({ command, exitCode });
|
|
98
|
+
}
|
|
99
|
+
if (reported.length === 0)
|
|
100
|
+
return { ok: true, items: [] };
|
|
101
|
+
if (!tree || !TREE_HASH.test(tree)) {
|
|
102
|
+
return { ok: false, error: "Hypher could not read the working tree, so it cannot record the checks against it." };
|
|
103
|
+
}
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const latest = [...reported].reverse().filter((item) => !seen.has(item.command) && seen.add(item.command)).reverse();
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
items: latest.map((item) => ({ command: item.command, exitCode: item.exitCode, tree, capturedBy: "agent", capturedAt: now })),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
62
111
|
function tooMany(field, count, hint) {
|
|
63
112
|
return `A handoff keeps at most ${MAX_HANDOFF_ITEMS} ${field}; this one would have ${count}. ${hint}`;
|
|
64
113
|
}
|
|
65
114
|
/**
|
|
115
|
+
* Carry rule: a save sends what it knows plus what it was handed, unchanged. Fields of the prior
|
|
116
|
+
* handoff that this code does not know are re-attached as they were: the top-level ones always,
|
|
117
|
+
* and those on a decision, source, or evidence item for as long as that item is carried forward.
|
|
118
|
+
* They leave with the item when it is replaced or dropped, because its owner was removed on
|
|
119
|
+
* purpose. Nothing this code did not read is invented or edited, and `repo` is replaced. The
|
|
120
|
+
* known fields are built only from the seal input and checked by the strict parser; the carried
|
|
121
|
+
* ones are not checked here, and the server refuses by name what it does not accept. A prior
|
|
122
|
+
* newer than version 1 is read-only: the save is refused.
|
|
123
|
+
*
|
|
66
124
|
* @param prior the proposal this session resumed from (null for the first handoff)
|
|
67
125
|
* @param sessionRef a unique label for this session, used as its source
|
|
68
126
|
*/
|
|
69
127
|
export function buildSealProposal(input) {
|
|
70
128
|
const { prior, seal } = input;
|
|
129
|
+
if (prior && prior.schemaVersion !== HANDOFF_PROPOSAL_SCHEMA_VERSION) {
|
|
130
|
+
return { ok: false, error: readOnlyHandoffMessage(prior.schemaVersion) };
|
|
131
|
+
}
|
|
71
132
|
const nextAction = clean(seal.nextAction);
|
|
72
133
|
if (!nextAction)
|
|
73
134
|
return { ok: false, error: "The next action is required: nextAction (in a shell, --next \"…\")." };
|
|
@@ -130,6 +191,18 @@ export function buildSealProposal(input) {
|
|
|
130
191
|
// save kept "finish CONTRIBUTING.md" and its stale lines). Decisions and constraints still carry.
|
|
131
192
|
const goalChanged = Boolean(prior && newGoal && newGoal !== prior.goal);
|
|
132
193
|
const listOrPrior = (value, fallback) => cleanList(value) ?? (goalChanged ? [] : fallback);
|
|
194
|
+
// Evidence follows the goal rule: kept while the goal holds, dropped when it changes. A newer
|
|
195
|
+
// entry for a command replaces the older one. This save's entries come first, then the earlier
|
|
196
|
+
// ones, so the list runs newest first; beyond eight, the oldest go.
|
|
197
|
+
const reported = evidenceFromSeal(seal.evidence, input.tree, input.now ?? Date.now());
|
|
198
|
+
if (!reported.ok)
|
|
199
|
+
return reported;
|
|
200
|
+
if (reported.items.length > MAX_HANDOFF_ITEMS) {
|
|
201
|
+
return { ok: false, error: tooMany("evidence entries", reported.items.length, "Report the checks the next agent needs.") };
|
|
202
|
+
}
|
|
203
|
+
const reportedCommands = new Set(reported.items.map((item) => item.command));
|
|
204
|
+
const carried = goalChanged ? [] : (prior?.evidence ?? []).filter((item) => !reportedCommands.has(item.command));
|
|
205
|
+
const evidence = [...reported.items, ...carried].slice(0, MAX_HANDOFF_ITEMS);
|
|
133
206
|
const proposal = {
|
|
134
207
|
schemaVersion: HANDOFF_PROPOSAL_SCHEMA_VERSION,
|
|
135
208
|
goal,
|
|
@@ -141,14 +214,19 @@ export function buildSealProposal(input) {
|
|
|
141
214
|
nextAction,
|
|
142
215
|
sources,
|
|
143
216
|
repo: input.repo,
|
|
217
|
+
...(evidence.length ? { evidence } : {}),
|
|
144
218
|
};
|
|
145
219
|
for (const field of ["completed", "unverified", "blockers"]) {
|
|
146
220
|
if (proposal[field].length > MAX_HANDOFF_ITEMS) {
|
|
147
221
|
return { ok: false, error: tooMany(field, proposal[field].length, "Keep the ones the next agent needs.") };
|
|
148
222
|
}
|
|
149
223
|
}
|
|
150
|
-
|
|
224
|
+
// The strict parser sees only the fields this code knows; the carried ones go back afterwards.
|
|
225
|
+
const sendable = knownFieldsV1(proposal);
|
|
226
|
+
if (!sendable)
|
|
227
|
+
return { ok: false, error: readOnlyHandoffMessage(proposal.schemaVersion) };
|
|
228
|
+
const parsed = parseHandoffProposal(sendable);
|
|
151
229
|
if (!parsed.ok)
|
|
152
230
|
return parsed;
|
|
153
|
-
return { ok: true, proposal: parsed.value };
|
|
231
|
+
return { ok: true, proposal: reattachUnknownFields(parsed.value, { ...prior, ...proposal }) };
|
|
154
232
|
}
|
|
@@ -13,6 +13,7 @@ const MAX_BRANCH = 200;
|
|
|
13
13
|
const MAX_COMMIT = 80;
|
|
14
14
|
const MAX_DESTINATION = 80;
|
|
15
15
|
const MAX_REASON = 500;
|
|
16
|
+
const MAX_COMMAND = 300;
|
|
16
17
|
const PROPOSAL_KEYS = new Set([
|
|
17
18
|
"schemaVersion",
|
|
18
19
|
"goal",
|
|
@@ -24,7 +25,9 @@ const PROPOSAL_KEYS = new Set([
|
|
|
24
25
|
"nextAction",
|
|
25
26
|
"sources",
|
|
26
27
|
"repo",
|
|
28
|
+
"evidence",
|
|
27
29
|
]);
|
|
30
|
+
const EVIDENCE_KEYS = new Set(["command", "exitCode", "tree", "capturedBy", "capturedAt"]);
|
|
28
31
|
export const HANDOFF_PROPOSAL_JSON_SCHEMA = {
|
|
29
32
|
type: "object",
|
|
30
33
|
additionalProperties: false,
|
|
@@ -93,6 +96,23 @@ export const HANDOFF_PROPOSAL_JSON_SCHEMA = {
|
|
|
93
96
|
},
|
|
94
97
|
description: "Working-tree metadata. This snapshot does not include file contents.",
|
|
95
98
|
},
|
|
99
|
+
evidence: {
|
|
100
|
+
type: "array",
|
|
101
|
+
maxItems: MAX_ITEMS,
|
|
102
|
+
description: "Optional. Checks you ran, newest first: the command text and its exit code, bound to the working-tree hash at the save. An agent report; Hypher does not run it. Never include output or file contents.",
|
|
103
|
+
items: {
|
|
104
|
+
type: "object",
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
required: ["command", "exitCode", "tree", "capturedBy", "capturedAt"],
|
|
107
|
+
properties: {
|
|
108
|
+
command: { type: "string", maxLength: MAX_COMMAND, description: "One line." },
|
|
109
|
+
exitCode: { type: "integer", minimum: 0, maximum: 255 },
|
|
110
|
+
tree: { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$", description: "Working-tree hash at the save: the same hash as repo.dirtyFingerprint, without the tree: prefix, and also when the tree is clean." },
|
|
111
|
+
capturedBy: { type: "string", enum: ["agent"] },
|
|
112
|
+
capturedAt: { type: "integer", minimum: 1, description: "Milliseconds since the epoch." },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
96
116
|
},
|
|
97
117
|
};
|
|
98
118
|
function isObject(value) {
|
|
@@ -156,6 +176,40 @@ export function parseRepoSnapshot(value) {
|
|
|
156
176
|
}
|
|
157
177
|
return { ok: true, value: result };
|
|
158
178
|
}
|
|
179
|
+
const TREE_HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
180
|
+
export function parseEvidence(value) {
|
|
181
|
+
if (!Array.isArray(value))
|
|
182
|
+
return { ok: false, error: "evidence must be an array" };
|
|
183
|
+
if (value.length > MAX_ITEMS)
|
|
184
|
+
return { ok: false, error: "evidence has too many items" };
|
|
185
|
+
const items = [];
|
|
186
|
+
for (const item of value) {
|
|
187
|
+
if (!isObject(item))
|
|
188
|
+
return { ok: false, error: "evidence must be objects" };
|
|
189
|
+
const extra = unknownKey(item, EVIDENCE_KEYS);
|
|
190
|
+
if (extra)
|
|
191
|
+
return { ok: false, error: `evidence.${extra} is not allowed` };
|
|
192
|
+
const command = shortText(item.command, "evidence.command", MAX_COMMAND);
|
|
193
|
+
if (!command.ok)
|
|
194
|
+
return command;
|
|
195
|
+
if (/[\r\n\u2028\u2029]/.test(command.value))
|
|
196
|
+
return { ok: false, error: "evidence.command must be one line" };
|
|
197
|
+
if (typeof item.exitCode !== "number" || !Number.isInteger(item.exitCode) || item.exitCode < 0 || item.exitCode > 255) {
|
|
198
|
+
return { ok: false, error: "evidence.exitCode must be a whole number from 0 to 255" };
|
|
199
|
+
}
|
|
200
|
+
if (typeof item.tree !== "string" || !TREE_HASH.test(item.tree)) {
|
|
201
|
+
return { ok: false, error: "evidence.tree must be a 40 or 64 character lowercase hex hash" };
|
|
202
|
+
}
|
|
203
|
+
if (item.capturedBy !== "agent") {
|
|
204
|
+
return { ok: false, error: "evidence.capturedBy must be agent" };
|
|
205
|
+
}
|
|
206
|
+
if (typeof item.capturedAt !== "number" || !Number.isInteger(item.capturedAt) || item.capturedAt < 1) {
|
|
207
|
+
return { ok: false, error: "evidence.capturedAt must be a positive whole number of milliseconds" };
|
|
208
|
+
}
|
|
209
|
+
items.push({ command: command.value, exitCode: item.exitCode, tree: item.tree, capturedBy: item.capturedBy, capturedAt: item.capturedAt });
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, value: items };
|
|
212
|
+
}
|
|
159
213
|
const PLACEHOLDER_PREFIX = "FILL:";
|
|
160
214
|
function isPlaceholder(value) {
|
|
161
215
|
return typeof value === "string" && value.trim().startsWith(PLACEHOLDER_PREFIX);
|
|
@@ -192,6 +246,8 @@ export function findHandoffPlaceholder(proposal) {
|
|
|
192
246
|
if (isPlaceholder(proposal.repo[field]))
|
|
193
247
|
return `repo.${field}`;
|
|
194
248
|
}
|
|
249
|
+
if (proposal.evidence?.some((item) => isPlaceholder(item.command)))
|
|
250
|
+
return "evidence.command";
|
|
195
251
|
return null;
|
|
196
252
|
}
|
|
197
253
|
/**
|
|
@@ -322,6 +378,13 @@ export function parseHandoffProposal(value, options = {}) {
|
|
|
322
378
|
const repo = parseRepoSnapshot(value.repo);
|
|
323
379
|
if (!repo.ok)
|
|
324
380
|
return repo;
|
|
381
|
+
let evidence;
|
|
382
|
+
if (value.evidence !== undefined) {
|
|
383
|
+
const parsedEvidence = parseEvidence(value.evidence);
|
|
384
|
+
if (!parsedEvidence.ok)
|
|
385
|
+
return parsedEvidence;
|
|
386
|
+
evidence = parsedEvidence.value;
|
|
387
|
+
}
|
|
325
388
|
const proposal = {
|
|
326
389
|
schemaVersion: 1,
|
|
327
390
|
goal: goal.value,
|
|
@@ -333,6 +396,7 @@ export function parseHandoffProposal(value, options = {}) {
|
|
|
333
396
|
nextAction: nextAction.value,
|
|
334
397
|
sources,
|
|
335
398
|
repo: repo.value,
|
|
399
|
+
...(evidence !== undefined ? { evidence } : {}),
|
|
336
400
|
};
|
|
337
401
|
if (!options.allowPlaceholders) {
|
|
338
402
|
const placeholder = findHandoffPlaceholder(proposal);
|
|
@@ -345,6 +409,248 @@ export function parseHandoffProposal(value, options = {}) {
|
|
|
345
409
|
}
|
|
346
410
|
return { ok: true, value: proposal };
|
|
347
411
|
}
|
|
412
|
+
const DECISION_KEYS = new Set(["decision", "reason", "status", "sourceRefs", "supersedes"]);
|
|
413
|
+
const SOURCE_KEYS = new Set(["ref", "label", "kind", "sourceId"]);
|
|
414
|
+
const REPO_KEYS = new Set(["repository", "branch", "commit", "dirty", "worktreePath", "dirtyFingerprint"]);
|
|
415
|
+
/** Required text, trimmed, with no length cap. */
|
|
416
|
+
function readText(value, field) {
|
|
417
|
+
return shortText(value, field, Number.POSITIVE_INFINITY);
|
|
418
|
+
}
|
|
419
|
+
function readTextList(value, field) {
|
|
420
|
+
if (!Array.isArray(value))
|
|
421
|
+
return { ok: false, error: `${field} must be an array` };
|
|
422
|
+
const items = [];
|
|
423
|
+
for (const item of value) {
|
|
424
|
+
const parsed = readText(item, field);
|
|
425
|
+
if (!parsed.ok)
|
|
426
|
+
return parsed;
|
|
427
|
+
items.push(parsed.value);
|
|
428
|
+
}
|
|
429
|
+
return { ok: true, value: items };
|
|
430
|
+
}
|
|
431
|
+
/** The own fields of `value` that are not in `known`, in their original order. */
|
|
432
|
+
function unknownFieldsOf(value, known) {
|
|
433
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => !known.has(key)));
|
|
434
|
+
}
|
|
435
|
+
function knownFieldsOnly(value, known) {
|
|
436
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => known.has(key)));
|
|
437
|
+
}
|
|
438
|
+
function readRepo(value) {
|
|
439
|
+
if (!isObject(value))
|
|
440
|
+
return { ok: false, error: "repo must be an object" };
|
|
441
|
+
const branch = readText(value.branch, "repo.branch");
|
|
442
|
+
if (!branch.ok)
|
|
443
|
+
return branch;
|
|
444
|
+
const commit = readText(value.commit, "repo.commit");
|
|
445
|
+
if (!commit.ok)
|
|
446
|
+
return commit;
|
|
447
|
+
if (typeof value.dirty !== "boolean")
|
|
448
|
+
return { ok: false, error: "repo.dirty must be a boolean" };
|
|
449
|
+
const result = { branch: branch.value, commit: commit.value, dirty: value.dirty };
|
|
450
|
+
for (const field of ["repository", "worktreePath", "dirtyFingerprint"]) {
|
|
451
|
+
if (value[field] === undefined)
|
|
452
|
+
continue;
|
|
453
|
+
const parsed = readText(value[field], `repo.${field}`);
|
|
454
|
+
if (!parsed.ok)
|
|
455
|
+
return parsed;
|
|
456
|
+
result[field] = parsed.value;
|
|
457
|
+
}
|
|
458
|
+
return { ok: true, value: result };
|
|
459
|
+
}
|
|
460
|
+
function readEvidence(value) {
|
|
461
|
+
if (!Array.isArray(value))
|
|
462
|
+
return { ok: false, error: "evidence must be an array" };
|
|
463
|
+
const items = [];
|
|
464
|
+
for (const item of value) {
|
|
465
|
+
if (!isObject(item))
|
|
466
|
+
return { ok: false, error: "evidence must be objects" };
|
|
467
|
+
const command = readText(item.command, "evidence.command");
|
|
468
|
+
if (!command.ok)
|
|
469
|
+
return command;
|
|
470
|
+
if (typeof item.exitCode !== "number" || !Number.isFinite(item.exitCode)) {
|
|
471
|
+
return { ok: false, error: "evidence.exitCode must be a number" };
|
|
472
|
+
}
|
|
473
|
+
const tree = readText(item.tree, "evidence.tree");
|
|
474
|
+
if (!tree.ok)
|
|
475
|
+
return tree;
|
|
476
|
+
if (item.capturedBy !== "agent" && item.capturedBy !== "hook") {
|
|
477
|
+
return { ok: false, error: "evidence.capturedBy must be agent or hook" };
|
|
478
|
+
}
|
|
479
|
+
if (typeof item.capturedAt !== "number" || !Number.isFinite(item.capturedAt)) {
|
|
480
|
+
return { ok: false, error: "evidence.capturedAt must be a number" };
|
|
481
|
+
}
|
|
482
|
+
items.push({
|
|
483
|
+
command: command.value,
|
|
484
|
+
exitCode: item.exitCode,
|
|
485
|
+
tree: tree.value,
|
|
486
|
+
capturedBy: item.capturedBy,
|
|
487
|
+
capturedAt: item.capturedAt,
|
|
488
|
+
...unknownFieldsOf(item, EVIDENCE_KEYS),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, value: items };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Read a handoff leniently: the reader of a stored handoff, which must not refuse a note because
|
|
495
|
+
* a newer Hypher wrote a field, a longer list, or a longer line than this code would. For any
|
|
496
|
+
* input `parseHandoffProposal` accepts, the known fields come out equal. A `schemaVersion` above 1
|
|
497
|
+
* parses but is flagged `readOnly`: the meaning may have changed, so a writer must not save over
|
|
498
|
+
* it. A missing, non-integer, or below-1 `schemaVersion` is an error.
|
|
499
|
+
*/
|
|
500
|
+
export function parseHandoffProposalLenient(value) {
|
|
501
|
+
if (!isObject(value))
|
|
502
|
+
return { ok: false, error: "proposal must be an object" };
|
|
503
|
+
const schemaVersion = value.schemaVersion;
|
|
504
|
+
if (typeof schemaVersion !== "number" || !Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
|
505
|
+
return { ok: false, error: "proposal.schemaVersion must be a whole number, 1 or higher" };
|
|
506
|
+
}
|
|
507
|
+
const goal = readText(value.goal, "goal");
|
|
508
|
+
if (!goal.ok)
|
|
509
|
+
return goal;
|
|
510
|
+
const constraints = readTextList(value.constraints, "constraints");
|
|
511
|
+
if (!constraints.ok)
|
|
512
|
+
return constraints;
|
|
513
|
+
const completed = readTextList(value.completed, "completed");
|
|
514
|
+
if (!completed.ok)
|
|
515
|
+
return completed;
|
|
516
|
+
const unverified = readTextList(value.unverified, "unverified");
|
|
517
|
+
if (!unverified.ok)
|
|
518
|
+
return unverified;
|
|
519
|
+
const blockers = readTextList(value.blockers, "blockers");
|
|
520
|
+
if (!blockers.ok)
|
|
521
|
+
return blockers;
|
|
522
|
+
const nextAction = readText(value.nextAction, "nextAction");
|
|
523
|
+
if (!nextAction.ok)
|
|
524
|
+
return nextAction;
|
|
525
|
+
if (!Array.isArray(value.decisions))
|
|
526
|
+
return { ok: false, error: "decisions must be an array" };
|
|
527
|
+
const decisions = [];
|
|
528
|
+
for (const item of value.decisions) {
|
|
529
|
+
if (!isObject(item))
|
|
530
|
+
return { ok: false, error: "decisions must be objects" };
|
|
531
|
+
const decision = readText(item.decision, "decisions.decision");
|
|
532
|
+
if (!decision.ok)
|
|
533
|
+
return decision;
|
|
534
|
+
const reason = readText(item.reason, "decisions.reason");
|
|
535
|
+
if (!reason.ok)
|
|
536
|
+
return reason;
|
|
537
|
+
const entry = { decision: decision.value, reason: reason.value };
|
|
538
|
+
if (item.status !== undefined) {
|
|
539
|
+
if (item.status !== "reported" && item.status !== "approved")
|
|
540
|
+
return { ok: false, error: "decisions.status is invalid" };
|
|
541
|
+
entry.status = item.status;
|
|
542
|
+
}
|
|
543
|
+
if (item.sourceRefs !== undefined) {
|
|
544
|
+
const sourceRefs = readTextList(item.sourceRefs, "decisions.sourceRefs");
|
|
545
|
+
if (!sourceRefs.ok)
|
|
546
|
+
return sourceRefs;
|
|
547
|
+
entry.sourceRefs = sourceRefs.value;
|
|
548
|
+
}
|
|
549
|
+
if (item.supersedes !== undefined) {
|
|
550
|
+
const supersedes = readText(item.supersedes, "decisions.supersedes");
|
|
551
|
+
if (!supersedes.ok)
|
|
552
|
+
return supersedes;
|
|
553
|
+
entry.supersedes = supersedes.value;
|
|
554
|
+
}
|
|
555
|
+
decisions.push({ ...entry, ...unknownFieldsOf(item, DECISION_KEYS) });
|
|
556
|
+
}
|
|
557
|
+
if (!Array.isArray(value.sources))
|
|
558
|
+
return { ok: false, error: "sources must be an array" };
|
|
559
|
+
const sources = [];
|
|
560
|
+
for (const item of value.sources) {
|
|
561
|
+
if (!isObject(item))
|
|
562
|
+
return { ok: false, error: "sources must be objects" };
|
|
563
|
+
const ref = readText(item.ref, "sources.ref");
|
|
564
|
+
if (!ref.ok)
|
|
565
|
+
return ref;
|
|
566
|
+
const source = { ref: ref.value };
|
|
567
|
+
if (item.label !== undefined) {
|
|
568
|
+
const label = readText(item.label, "sources.label");
|
|
569
|
+
if (!label.ok)
|
|
570
|
+
return label;
|
|
571
|
+
source.label = label.value;
|
|
572
|
+
}
|
|
573
|
+
if (item.kind !== undefined) {
|
|
574
|
+
if (item.kind !== "agent_report" && item.kind !== "capture" && item.kind !== "agent_event") {
|
|
575
|
+
return { ok: false, error: "sources.kind is invalid" };
|
|
576
|
+
}
|
|
577
|
+
source.kind = item.kind;
|
|
578
|
+
}
|
|
579
|
+
if (item.sourceId !== undefined) {
|
|
580
|
+
const sourceId = readText(item.sourceId, "sources.sourceId");
|
|
581
|
+
if (!sourceId.ok)
|
|
582
|
+
return sourceId;
|
|
583
|
+
source.sourceId = sourceId.value;
|
|
584
|
+
}
|
|
585
|
+
sources.push({ ...source, ...unknownFieldsOf(item, SOURCE_KEYS) });
|
|
586
|
+
}
|
|
587
|
+
const repo = readRepo(value.repo);
|
|
588
|
+
if (!repo.ok)
|
|
589
|
+
return repo;
|
|
590
|
+
let evidence;
|
|
591
|
+
if (value.evidence !== undefined) {
|
|
592
|
+
const read = readEvidence(value.evidence);
|
|
593
|
+
if (!read.ok)
|
|
594
|
+
return read;
|
|
595
|
+
evidence = read.value;
|
|
596
|
+
}
|
|
597
|
+
const proposal = {
|
|
598
|
+
schemaVersion,
|
|
599
|
+
goal: goal.value,
|
|
600
|
+
constraints: constraints.value,
|
|
601
|
+
decisions,
|
|
602
|
+
completed: completed.value,
|
|
603
|
+
unverified: unverified.value,
|
|
604
|
+
blockers: blockers.value,
|
|
605
|
+
nextAction: nextAction.value,
|
|
606
|
+
sources,
|
|
607
|
+
repo: repo.value,
|
|
608
|
+
...(evidence !== undefined ? { evidence } : {}),
|
|
609
|
+
...unknownFieldsOf(value, PROPOSAL_KEYS),
|
|
610
|
+
};
|
|
611
|
+
return { ok: true, value: proposal, readOnly: schemaVersion > HANDOFF_PROPOSAL_SCHEMA_VERSION };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* A read handoff as version 1 with only the fields this code knows: unknown fields are dropped at
|
|
615
|
+
* the top level and on each decision, source, and evidence item. Null above version 1, whose known
|
|
616
|
+
* fields may no longer mean what they meant. What a writer sends to be checked by the strict parser.
|
|
617
|
+
*/
|
|
618
|
+
export function knownFieldsV1(read) {
|
|
619
|
+
if (read.schemaVersion !== HANDOFF_PROPOSAL_SCHEMA_VERSION)
|
|
620
|
+
return null;
|
|
621
|
+
return {
|
|
622
|
+
schemaVersion: HANDOFF_PROPOSAL_SCHEMA_VERSION,
|
|
623
|
+
goal: read.goal,
|
|
624
|
+
constraints: read.constraints,
|
|
625
|
+
decisions: read.decisions.map((item) => knownFieldsOnly(item, DECISION_KEYS)),
|
|
626
|
+
completed: read.completed,
|
|
627
|
+
unverified: read.unverified,
|
|
628
|
+
blockers: read.blockers,
|
|
629
|
+
nextAction: read.nextAction,
|
|
630
|
+
sources: read.sources.map((item) => knownFieldsOnly(item, SOURCE_KEYS)),
|
|
631
|
+
repo: knownFieldsOnly(read.repo, REPO_KEYS),
|
|
632
|
+
...(read.evidence ? { evidence: read.evidence.map((item) => knownFieldsOnly(item, EVIDENCE_KEYS)) } : {}),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Put back, unchanged, the unknown fields `carrier` held: the top-level ones, and those of each
|
|
637
|
+
* decision, source, and evidence item. `known` must be what a parse of `knownFieldsV1(carrier)`
|
|
638
|
+
* returns, or any list built from it, with the same items in the same order: the fields go back
|
|
639
|
+
* by position. Nothing is invented or edited.
|
|
640
|
+
*/
|
|
641
|
+
export function reattachUnknownFields(known, carrier) {
|
|
642
|
+
const back = (items, from, keys) => items.map((item, index) => {
|
|
643
|
+
const owner = from?.[index];
|
|
644
|
+
return isObject(owner) ? { ...item, ...unknownFieldsOf(owner, keys) } : item;
|
|
645
|
+
});
|
|
646
|
+
return {
|
|
647
|
+
...known,
|
|
648
|
+
...unknownFieldsOf(carrier, PROPOSAL_KEYS),
|
|
649
|
+
decisions: back(known.decisions, carrier.decisions, DECISION_KEYS),
|
|
650
|
+
sources: back(known.sources, carrier.sources, SOURCE_KEYS),
|
|
651
|
+
...(known.evidence ? { evidence: back(known.evidence, carrier.evidence, EVIDENCE_KEYS) } : {}),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
348
654
|
export function renderHandoffPacket(proposal) {
|
|
349
655
|
const lines = [
|
|
350
656
|
`Goal: ${proposal.goal}`,
|
|
@@ -354,6 +660,9 @@ export function renderHandoffPacket(proposal) {
|
|
|
354
660
|
proposal.completed.length ? `Agent-reported completed work: ${proposal.completed.join("; ")}` : "Agent-reported completed work: none",
|
|
355
661
|
proposal.unverified.length ? `Unverified: ${proposal.unverified.join("; ")}` : "Unverified: none",
|
|
356
662
|
proposal.blockers.length ? `Blockers: ${proposal.blockers.join("; ")}` : "Blockers: none",
|
|
663
|
+
...(proposal.evidence?.length
|
|
664
|
+
? [`Evidence (agent reports against the tree at the save; Hypher did not run them): ${proposal.evidence.map((item) => `${item.command} exited ${item.exitCode}`).join("; ")}`]
|
|
665
|
+
: []),
|
|
357
666
|
`Next action: ${proposal.nextAction}`,
|
|
358
667
|
"Sources:",
|
|
359
668
|
...proposal.sources.map((item) => `- ${item.kind === "capture" ? "Project capture" : item.kind === "agent_event" ? "Stored agent report" : "Agent-reported"}: ${item.label ? `${item.label}: ` : ""}${item.ref}`),
|
|
@@ -546,6 +855,30 @@ export function planHandoffDelivery(input) {
|
|
|
546
855
|
receipt,
|
|
547
856
|
};
|
|
548
857
|
}
|
|
858
|
+
export function headFromRow(projectId, row) {
|
|
859
|
+
if (!row || typeof row.revision !== "number")
|
|
860
|
+
return { kind: "none" };
|
|
861
|
+
const unreadable = (error) => ({ kind: "unreadable", revision: row.revision, error });
|
|
862
|
+
if (!row.proposal || !row.idempotencyKey || !row.sourceAgent)
|
|
863
|
+
return unreadable("the stored row is incomplete");
|
|
864
|
+
const parsed = parseHandoffProposalLenient(row.proposal);
|
|
865
|
+
if (!parsed.ok)
|
|
866
|
+
return unreadable(parsed.error);
|
|
867
|
+
const proposal = knownFieldsV1(parsed.value);
|
|
868
|
+
if (!proposal)
|
|
869
|
+
return unreadable(`it is format version ${parsed.value.schemaVersion}, newer than this server reads`);
|
|
870
|
+
return {
|
|
871
|
+
kind: "head",
|
|
872
|
+
head: {
|
|
873
|
+
projectId,
|
|
874
|
+
revision: row.revision,
|
|
875
|
+
idempotencyKey: row.idempotencyKey,
|
|
876
|
+
proposal,
|
|
877
|
+
source: row.sourceAgent,
|
|
878
|
+
savedAt: row.generatedAt,
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
}
|
|
549
882
|
export function readHandoffCommand(value) {
|
|
550
883
|
if (!isObject(value))
|
|
551
884
|
return { ok: true, command: null };
|